//-----------------------------------------------------------------------
//
// Copyright (c) Andrew Arnott. All rights reserved.
//
//-----------------------------------------------------------------------
namespace DotNetOpenAuth.OpenId {
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Diagnostics.Contracts;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Net;
using System.Security;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using System.Security.Permissions;
using System.Text;
using System.Text.RegularExpressions;
using System.Xml;
using System.Xml.XPath;
using DotNetOpenAuth.Messaging;
using DotNetOpenAuth.OpenId.RelyingParty;
using DotNetOpenAuth.Xrds;
using DotNetOpenAuth.Yadis;
///
/// The discovery service to support host-meta based discovery, such as Google Apps for Domains.
///
///
/// The spec for this discovery mechanism can be found at:
/// http://groups.google.com/group/google-federated-login-api/web/openid-discovery-for-hosted-domains
/// and the XMLDSig spec referenced in that spec can be found at:
/// http://wiki.oasis-open.org/xri/XrdOne/XmlDsigProfile
///
public class HostMetaDiscoveryService : IIdentifierDiscoveryService {
///
/// The URI template for discovery host-meta on domains hosted by
/// Google Apps for Domains.
///
private static readonly HostMetaProxy GoogleHostedHostMeta = new HostMetaProxy("https://www.google.com/accounts/o8/.well-known/host-meta?hd={0}", "hosted-id.google.com");
///
/// Path to the well-known location of the host-meta document at a domain.
///
private const string LocalHostMetaPath = "/.well-known/host-meta";
///
/// The pattern within a host-meta file to look for to obtain the URI to the XRDS document.
///
private static readonly Regex HostMetaLink = new Regex(@"^Link: <(?.+?)>; rel=""describedby http://reltype.google.com/openid/xrd-op""; type=""application/xrds\+xml""$");
///
/// Initializes a new instance of the class.
///
public HostMetaDiscoveryService() {
this.TrustedHostMetaProxies = new List();
}
///
/// Gets the set of URI templates to use to contact host-meta hosting proxies
/// for domain discovery.
///
public IList TrustedHostMetaProxies { get; private set; }
///
/// Gets or sets a value indicating whether to trust Google to host domains' host-meta documents.
///
///
/// This property is just a convenient mechanism for checking or changing the set of
/// trusted host-meta proxies in the property.
///
public bool UseGoogleHostedHostMeta {
get {
return this.TrustedHostMetaProxies.Contains(GoogleHostedHostMeta);
}
set {
if (value != this.UseGoogleHostedHostMeta) {
if (value) {
this.TrustedHostMetaProxies.Add(GoogleHostedHostMeta);
} else {
this.TrustedHostMetaProxies.Remove(GoogleHostedHostMeta);
}
}
}
}
#region IIdentifierDiscoveryService Members
///
/// Performs discovery on the specified identifier.
///
/// The identifier to perform discovery on.
/// The means to place outgoing HTTP requests.
/// if set to true, no further discovery services will be called for this identifier.
///
/// A sequence of service endpoints yielded by discovery. Must not be null, but may be empty.
///
public IEnumerable Discover(Identifier identifier, IDirectWebRequestHandler requestHandler, out bool abortDiscoveryChain) {
abortDiscoveryChain = false;
// Google Apps are always URIs -- not XRIs.
var uriIdentifier = identifier as UriIdentifier;
if (uriIdentifier == null) {
return Enumerable.Empty();
}
var results = new List();
string signingHost;
using (var response = GetXrdsResponse(uriIdentifier, requestHandler, out signingHost)) {
if (response != null) {
try {
var document = new XrdsDocument(XmlReader.Create(response.ResponseStream));
ValidateXmlDSig(document, uriIdentifier, response, signingHost);
var xrds = GetXrdElements(document, uriIdentifier.Uri.Host);
// Look for claimed identifier template URIs for an additional XRDS document.
results.AddRange(GetExternalServices(xrds, uriIdentifier, requestHandler));
// If we couldn't find any claimed identifiers, look for OP identifiers.
// Normally this would be the opposite (OP Identifiers take precedence over
// claimed identifiers, but for Google Apps, XRDS' always have OP Identifiers
// mixed in, which the OpenID spec mandate should eclipse Claimed Identifiers,
// which would break positive assertion checks).
if (results.Count == 0) {
results.AddRange(xrds.CreateServiceEndpoints(uriIdentifier, uriIdentifier));
}
abortDiscoveryChain = true;
} catch (XmlException ex) {
Logger.Yadis.ErrorFormat("Error while parsing XRDS document at {0} pointed to by host-meta: {1}", response.FinalUri, ex);
}
}
}
return results;
}
#endregion
///
/// Gets the XRD elements that have a given CanonicalID.
///
/// The XRDS document.
/// The CanonicalID to match on.
/// A sequence of XRD elements.
private static IEnumerable GetXrdElements(XrdsDocument document, string canonicalId) {
// filter to include only those XRD elements describing the host whose host-meta pointed us to this document.
return document.XrdElements.Where(xrd => string.Equals(xrd.CanonicalID, canonicalId, StringComparison.Ordinal));
}
///
/// Gets the described-by services in XRD elements.
///
/// The XRDs to search.
/// A sequence of services.
private static IEnumerable GetDescribedByServices(IEnumerable xrds) {
Contract.Requires(xrds != null);
Contract.Ensures(Contract.Result>() != null);
var describedBy = from xrd in xrds
from service in xrd.SearchForServiceTypeUris(p => "http://www.iana.org/assignments/relation/describedby")
select service;
return describedBy;
}
///
/// Gets the services for an identifier that are described by an external XRDS document.
///
/// The XRD elements to search for described-by services.
/// The identifier under discovery.
/// The request handler.
/// The discovered services.
private static IEnumerable GetExternalServices(IEnumerable xrds, UriIdentifier identifier, IDirectWebRequestHandler requestHandler) {
Contract.Requires(xrds != null);
Contract.Requires(identifier != null);
Contract.Requires(requestHandler != null);
Contract.Ensures(Contract.Result>() != null);
var results = new List();
foreach (var serviceElement in GetDescribedByServices(xrds)) {
var templateNode = serviceElement.Node.SelectSingleNode("google:URITemplate", serviceElement.XmlNamespaceResolver);
var nextAuthorityNode = serviceElement.Node.SelectSingleNode("google:NextAuthority", serviceElement.XmlNamespaceResolver);
if (templateNode != null) {
Uri externalLocation = new Uri(templateNode.Value.Trim().Replace("{%uri}", Uri.EscapeDataString(identifier.Uri.AbsoluteUri)));
string nextAuthority = nextAuthorityNode != null ? nextAuthorityNode.Value.Trim() : identifier.Uri.Host;
try {
using (var externalXrdsResponse = GetXrdsResponse(identifier, requestHandler, externalLocation)) {
XrdsDocument externalXrds = new XrdsDocument(XmlReader.Create(externalXrdsResponse.ResponseStream));
ValidateXmlDSig(externalXrds, identifier, externalXrdsResponse, nextAuthority);
results.AddRange(GetXrdElements(externalXrds, identifier).CreateServiceEndpoints(identifier, identifier));
}
} catch (ProtocolException ex) {
Logger.Yadis.WarnFormat("HTTP GET error while retrieving described-by XRDS document {0}: {1}", externalLocation.AbsoluteUri, ex);
} catch (XmlException ex) {
Logger.Yadis.ErrorFormat("Error while parsing described-by XRDS document {0}: {1}", externalLocation.AbsoluteUri, ex);
}
}
}
return results;
}
///
/// Validates the XML digital signature on an XRDS document.
///
/// The XRDS document whose signature should be validated.
/// The identifier under discovery.
/// The response.
/// The host name on the certificate that should be used to verify the signature in the XRDS.
/// Thrown if the XRDS document has an invalid or a missing signature.
[SuppressMessage("Microsoft.Naming", "CA2204:Literals should be spelled correctly", MessageId = "XmlDSig", Justification = "xml")]
private static void ValidateXmlDSig(XrdsDocument document, UriIdentifier identifier, IncomingWebResponse response, string signingHost) {
Contract.Requires(document != null);
Contract.Requires(identifier != null);
Contract.Requires(response != null);
var signatureNode = document.Node.SelectSingleNode("/xrds:XRDS/ds:Signature", document.XmlNamespaceResolver);
ErrorUtilities.VerifyProtocol(signatureNode != null, OpenIdStrings.MissingElement, "Signature");
var signedInfoNode = signatureNode.SelectSingleNode("ds:SignedInfo", document.XmlNamespaceResolver);
ErrorUtilities.VerifyProtocol(signedInfoNode != null, OpenIdStrings.MissingElement, "SignedInfo");
ErrorUtilities.VerifyProtocol(
signedInfoNode.SelectSingleNode("ds:CanonicalizationMethod[@Algorithm='http://docs.oasis-open.org/xri/xrd/2009/01#canonicalize-raw-octets']", document.XmlNamespaceResolver) != null,
OpenIdStrings.UnsupportedCanonicalizationMethod);
ErrorUtilities.VerifyProtocol(
signedInfoNode.SelectSingleNode("ds:SignatureMethod[@Algorithm='http://www.w3.org/2000/09/xmldsig#rsa-sha1']", document.XmlNamespaceResolver) != null,
OpenIdStrings.UnsupportedSignatureMethod);
var certNodes = signatureNode.Select("ds:KeyInfo/ds:X509Data/ds:X509Certificate", document.XmlNamespaceResolver);
ErrorUtilities.VerifyProtocol(certNodes.Count > 0, OpenIdStrings.MissingElement, "X509Certificate");
var certs = certNodes.Cast().Select(n => new X509Certificate2(Convert.FromBase64String(n.Value.Trim()))).ToList();
// Verify that we trust the signer of the certificates.
// Start by trying to validate just the certificate used to sign the XRDS document,
// since we can do that with partial trust.
Logger.OpenId.Debug("Verifying that we trust the certificate used to sign the discovery document.");
if (!certs[0].Verify()) {
// We couldn't verify just the signing certificate, so try to verify the whole certificate chain.
try {
Logger.OpenId.Debug("Verifying the whole certificate chain.");
VerifyCertChain(certs);
Logger.OpenId.Debug("Certificate chain verified.");
} catch (SecurityException) {
Logger.Yadis.Warn("Signing certificate verification failed and we have insufficient code access security permissions to perform certificate chain validation.");
ErrorUtilities.ThrowProtocol(OpenIdStrings.X509CertificateNotTrusted);
}
}
// Verify that the certificate is issued to the host on whom we are performing discovery.
string hostName = certs[0].GetNameInfo(X509NameType.DnsName, false);
ErrorUtilities.VerifyProtocol(string.Equals(hostName, signingHost, StringComparison.OrdinalIgnoreCase), OpenIdStrings.MisdirectedSigningCertificate, hostName, signingHost);
// Verify the signature itself
byte[] signature = Convert.FromBase64String(response.Headers["Signature"]);
var provider = (RSACryptoServiceProvider)certs.First().PublicKey.Key;
byte[] data = new byte[response.ResponseStream.Length];
response.ResponseStream.Seek(0, SeekOrigin.Begin);
response.ResponseStream.Read(data, 0, data.Length);
ErrorUtilities.VerifyProtocol(provider.VerifyData(data, "SHA1", signature), OpenIdStrings.InvalidDSig);
}
///
/// Verifies the cert chain.
///
/// The certs.
///
/// This must be in a method of its own because there is a LinkDemand on the
/// method. By being in a method of its own, the caller of this method may catch a
/// that is thrown if we're not running with full trust and execute
/// an alternative plan.
///
/// Thrown if the certificate chain is invalid or unverifiable.
[SuppressMessage("Microsoft.Globalization", "CA1303:Do not pass literals as localized parameters", MessageId = "DotNetOpenAuth.Messaging.ErrorUtilities.ThrowProtocol(System.String,System.Object[])", Justification = "The localized portion is a string resource already."), SuppressMessage("Microsoft.Security", "CA2122:DoNotIndirectlyExposeMethodsWithLinkDemands", Justification = "By design")]
private static void VerifyCertChain(List certs) {
var chain = new X509Chain();
foreach (var cert in certs) {
chain.Build(cert);
}
if (chain.ChainStatus.Length > 0) {
ErrorUtilities.ThrowProtocol(
string.Format(
CultureInfo.CurrentCulture,
OpenIdStrings.X509CertificateNotTrusted + " {0}",
string.Join(", ", chain.ChainStatus.Select(status => status.StatusInformation).ToArray())));
}
}
///
/// Gets the XRDS HTTP response for a given identifier.
///
/// The identifier.
/// The request handler.
/// The location of the XRDS document to retrieve.
///
/// A HTTP response carrying an XRDS document.
///
/// Thrown if the XRDS document could not be obtained.
private static IncomingWebResponse GetXrdsResponse(UriIdentifier identifier, IDirectWebRequestHandler requestHandler, Uri xrdsLocation) {
Contract.Requires(identifier != null);
Contract.Requires(requestHandler != null);
Contract.Requires(xrdsLocation != null);
Contract.Ensures(Contract.Result() != null);
var request = (HttpWebRequest)WebRequest.Create(xrdsLocation);
request.CachePolicy = Yadis.IdentifierDiscoveryCachePolicy;
request.Accept = ContentTypes.Xrds;
var options = identifier.IsDiscoverySecureEndToEnd ? DirectWebRequestOptions.RequireSsl : DirectWebRequestOptions.None;
var response = requestHandler.GetResponse(request, options).GetSnapshot(Yadis.MaximumResultToScan);
if (!string.Equals(response.ContentType.MediaType, ContentTypes.Xrds, StringComparison.Ordinal)) {
Logger.Yadis.WarnFormat("Host-meta pointed to XRDS at {0}, but Content-Type at that URL was unexpected value '{1}'.", xrdsLocation, response.ContentType);
}
return response;
}
///
/// Gets the XRDS HTTP response for a given identifier.
///
/// The identifier.
/// The request handler.
/// The host name on the certificate that should be used to verify the signature in the XRDS.
/// A HTTP response carrying an XRDS document, or null if one could not be obtained.
/// Thrown if the XRDS document could not be obtained.
private IncomingWebResponse GetXrdsResponse(UriIdentifier identifier, IDirectWebRequestHandler requestHandler, out string signingHost) {
Contract.Requires(identifier != null);
Contract.Requires(requestHandler != null);
Uri xrdsLocation = this.GetXrdsLocation(identifier, requestHandler, out signingHost);
if (xrdsLocation == null) {
return null;
}
var response = GetXrdsResponse(identifier, requestHandler, xrdsLocation);
return response;
}
///
/// Gets the location of the XRDS document that describes a given identifier.
///
/// The identifier under discovery.
/// The request handler.
/// The host name on the certificate that should be used to verify the signature in the XRDS.
/// An absolute URI, or null if one could not be determined.
private Uri GetXrdsLocation(UriIdentifier identifier, IDirectWebRequestHandler requestHandler, out string signingHost) {
Contract.Requires(identifier != null);
Contract.Requires(requestHandler != null);
using (var hostMetaResponse = this.GetHostMeta(identifier, requestHandler, out signingHost)) {
if (hostMetaResponse == null) {
return null;
}
using (var sr = hostMetaResponse.GetResponseReader()) {
string line = sr.ReadLine();
Match m = HostMetaLink.Match(line);
if (m.Success) {
Uri location = new Uri(m.Groups["location"].Value);
Logger.Yadis.InfoFormat("Found link to XRDS at {0} in host-meta document {1}.", location, hostMetaResponse.FinalUri);
return location;
}
}
Logger.Yadis.WarnFormat("Could not find link to XRDS in host-meta document: {0}", hostMetaResponse.FinalUri);
return null;
}
}
///
/// Gets the host-meta for a given identifier.
///
/// The identifier.
/// The request handler.
/// The host name on the certificate that should be used to verify the signature in the XRDS.
///
/// The host-meta response, or null if no host-meta document could be obtained.
///
private IncomingWebResponse GetHostMeta(UriIdentifier identifier, IDirectWebRequestHandler requestHandler, out string signingHost) {
Contract.Requires(identifier != null);
Contract.Requires(requestHandler != null);
foreach (var hostMetaProxy in this.GetHostMetaLocations(identifier)) {
var hostMetaLocation = hostMetaProxy.GetProxy(identifier);
var request = (HttpWebRequest)WebRequest.Create(hostMetaLocation);
request.CachePolicy = Yadis.IdentifierDiscoveryCachePolicy;
var options = DirectWebRequestOptions.AcceptAllHttpResponses;
if (identifier.IsDiscoverySecureEndToEnd) {
options |= DirectWebRequestOptions.RequireSsl;
}
var response = requestHandler.GetResponse(request, options).GetSnapshot(Yadis.MaximumResultToScan);
try {
if (response.Status == HttpStatusCode.OK) {
Logger.Yadis.InfoFormat("Found host-meta for {0} at: {1}", identifier.Uri.Host, hostMetaLocation);
signingHost = hostMetaProxy.GetSigningHost(identifier);
return response;
} else {
Logger.Yadis.InfoFormat("Could not obtain host-meta for {0} from {1}", identifier.Uri.Host, hostMetaLocation);
response.Dispose();
}
} catch {
response.Dispose();
throw;
}
}
signingHost = null;
return null;
}
///
/// Gets the URIs authorized to host host-meta documents on behalf of a given domain.
///
/// The identifier.
/// A sequence of URIs that MAY provide the host-meta for a given identifier.
private IEnumerable GetHostMetaLocations(UriIdentifier identifier) {
Contract.Requires(identifier != null);
// First try the proxies, as they are considered more "secure" than the local
// host-meta for a domain since the domain may be defaced.
IEnumerable result = this.TrustedHostMetaProxies;
// Finally, look for the local host-meta.
UriBuilder localHostMetaBuilder = new UriBuilder();
localHostMetaBuilder.Scheme = identifier.IsDiscoverySecureEndToEnd || identifier.Uri.IsTransportSecure() ? Uri.UriSchemeHttps : Uri.UriSchemeHttp;
localHostMetaBuilder.Host = identifier.Uri.Host;
localHostMetaBuilder.Path = LocalHostMetaPath;
result = result.Concat(new[] { new HostMetaProxy(localHostMetaBuilder.Uri.AbsoluteUri, identifier.Uri.Host) });
return result;
}
///
/// A description of a web server that hosts host-meta documents.
///
[SuppressMessage("Microsoft.Design", "CA1034:NestedTypesShouldNotBeVisible", Justification = "By design")]
public class HostMetaProxy {
///
/// Initializes a new instance of the class.
///
/// The proxy formatting string.
/// The signing host formatting string.
public HostMetaProxy(string proxyFormat, string signingHostFormat) {
Contract.Requires(!String.IsNullOrEmpty(proxyFormat));
Contract.Requires(!String.IsNullOrEmpty(signingHostFormat));
this.ProxyFormat = proxyFormat;
this.SigningHostFormat = signingHostFormat;
}
///
/// Gets the URL of the host-meta proxy.
///
/// The absolute proxy URL, which may include {0} to be replaced with the host of the identifier to be discovered.
public string ProxyFormat { get; private set; }
///
/// Gets the formatting string to determine the expected host name on the certificate
/// that is expected to be used to sign the XRDS document.
///
///
/// Either a string literal, or a formatting string where these placeholders may exist:
/// {0} the host on the identifier discovery was originally performed on;
/// {1} the host on this proxy.
///
public string SigningHostFormat { get; private set; }
///
/// Gets the absolute proxy URI.
///
/// The identifier being discovered.
/// The an absolute URI.
public virtual Uri GetProxy(UriIdentifier identifier) {
Contract.Requires(identifier != null);
return new Uri(string.Format(CultureInfo.InvariantCulture, this.ProxyFormat, Uri.EscapeDataString(identifier.Uri.Host)));
}
///
/// Gets the signing host URI.
///
/// The identifier being discovered.
/// A host name.
public virtual string GetSigningHost(UriIdentifier identifier) {
Contract.Requires(identifier != null);
return string.Format(CultureInfo.InvariantCulture, this.SigningHostFormat, identifier.Uri.Host, this.GetProxy(identifier).Host);
}
///
/// Determines whether the specified is equal to the current .
///
/// The to compare with the current .
///
/// true if the specified is equal to the current ; otherwise, false.
///
///
/// The parameter is null.
///
public override bool Equals(object obj) {
var other = obj as HostMetaProxy;
if (other == null) {
return false;
}
return this.ProxyFormat == other.ProxyFormat && this.SigningHostFormat == other.SigningHostFormat;
}
///
/// Serves as a hash function for a particular type.
///
///
/// A hash code for the current .
///
public override int GetHashCode() {
return this.ProxyFormat.GetHashCode();
}
}
}
}