//-----------------------------------------------------------------------
//
// Copyright (c) Outercurve Foundation. All rights reserved.
//
//-----------------------------------------------------------------------
namespace DotNetOpenAuth.OpenId {
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Net;
using System.Net.Cache;
using System.Net.Http;
using System.Net.Http.Headers;
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.Threading;
using System.Threading.Tasks;
using System.Xml;
using System.Xml.XPath;
using DotNetOpenAuth.Configuration;
using DotNetOpenAuth.Messaging;
using DotNetOpenAuth.OpenId.RelyingParty;
using DotNetOpenAuth.Xrds;
using DotNetOpenAuth.Yadis;
using Validation;
///
/// 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, IRequireHostFactories {
///
/// 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""$");
///
/// A set of certificate thumbprints that have been verified.
///
private static readonly HashSet ApprovedCertificateThumbprintCache = new HashSet(StringComparer.Ordinal);
///
/// Initializes a new instance of the class.
///
public HostMetaDiscoveryService() {
this.TrustedHostMetaProxies = new List();
}
public IHostFactories HostFactories { get; set; }
///
/// 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 async Task DiscoverAsync(Identifier identifier, CancellationToken cancellationToken) {
Requires.NotNull(identifier, "identifier");
Verify.Operation(this.HostFactories != null, Strings.HostFactoriesRequired);
cancellationToken.ThrowIfCancellationRequested();
// Google Apps are always URIs -- not XRIs.
var uriIdentifier = identifier as UriIdentifier;
if (uriIdentifier == null) {
return new IdentifierDiscoveryServiceResult(Enumerable.Empty());
}
var results = new List();
using (var response = await this.GetXrdsResponseAsync(uriIdentifier, cancellationToken)) {
if (response.Result != null) {
try {
var readerSettings = MessagingUtilities.CreateUntrustedXmlReaderSettings();
var responseStream = await response.Result.Content.ReadAsStreamAsync();
var document = new XrdsDocument(XmlReader.Create(responseStream, readerSettings));
await ValidateXmlDSigAsync(document, uriIdentifier, response.Result, response.SigningHost);
var xrds = GetXrdElements(document, uriIdentifier.Uri.Host);
// Look for claimed identifier template URIs for an additional XRDS document.
results.AddRange(await this.GetExternalServicesAsync(xrds, uriIdentifier, cancellationToken));
// 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));
}
} catch (XmlException ex) {
Logger.Yadis.ErrorFormat("Error while parsing XRDS document at {0} pointed to by host-meta: {1}", response.Result.RequestMessage.RequestUri, ex);
}
}
}
return new IdentifierDiscoveryServiceResult(results, abortDiscoveryChain: true);
}
#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) {
Requires.NotNull(xrds, "xrds");
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 async Task> GetExternalServicesAsync(IEnumerable xrds, UriIdentifier identifier, CancellationToken cancellationToken) {
Requires.NotNull(xrds, "xrds");
Requires.NotNull(identifier, "identifier");
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 = await this.GetXrdsResponseAsync(identifier, externalLocation, cancellationToken)) {
var readerSettings = MessagingUtilities.CreateUntrustedXmlReaderSettings();
var responseStream = await externalXrdsResponse.Content.ReadAsStreamAsync();
XrdsDocument externalXrds = new XrdsDocument(XmlReader.Create(responseStream, readerSettings));
await ValidateXmlDSigAsync(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 async Task ValidateXmlDSigAsync(XrdsDocument document, UriIdentifier identifier, HttpResponseMessage response, string signingHost) {
Requires.NotNull(document, "document");
Requires.NotNull(identifier, "identifier");
Requires.NotNull(response, "response");
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();
VerifyCertificateChain(certs);
// 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.GetValues("Signature").First());
var provider = (RSACryptoServiceProvider)certs.First().PublicKey.Key;
var responseStream = await response.Content.ReadAsStreamAsync();
byte[] data = new byte[responseStream.Length];
responseStream.Seek(0, SeekOrigin.Begin);
await responseStream.ReadAsync(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(IEnumerable 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 async Task GetXrdsResponseAsync(UriIdentifier identifier, Uri xrdsLocation, CancellationToken cancellationToken) {
Requires.NotNull(identifier, "identifier");
Requires.NotNull(xrdsLocation, "xrdsLocation");
using (var httpClient = this.HostFactories.CreateHttpClient(identifier.IsDiscoverySecureEndToEnd, Yadis.IdentifierDiscoveryCachePolicy)) {
var request = new HttpRequestMessage(HttpMethod.Get, xrdsLocation);
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue(ContentTypes.Xrds));
var response = await httpClient.SendAsync(request, cancellationToken);
try {
if (!string.Equals(response.Content.Headers.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.Content.Headers.ContentType);
}
return response;
} catch {
response.Dispose();
throw;
}
}
}
///
/// Verifies that a certificate chain is trusted.
///
/// The chain of certificates to verify.
private static void VerifyCertificateChain(IList certificates) {
Requires.NotNullEmptyOrNullElements(certificates, "certificates");
// Before calling into the OS to validate the certificate, since that can for some bizzare reason hang for 5 seconds
// on some systems, check a cache of previously verified certificates first.
if (OpenIdElement.Configuration.RelyingParty.HostMetaDiscovery.EnableCertificateValidationCache) {
lock (ApprovedCertificateThumbprintCache) {
// HashSet isn't thread-safe.
if (ApprovedCertificateThumbprintCache.Contains(certificates[0].Thumbprint)) {
return;
}
}
}
// 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 (!certificates[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(certificates);
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);
}
}
if (OpenIdElement.Configuration.RelyingParty.HostMetaDiscovery.EnableCertificateValidationCache) {
lock (ApprovedCertificateThumbprintCache) {
ApprovedCertificateThumbprintCache.Add(certificates[0].Thumbprint);
}
}
}
///
/// 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 async Task> GetXrdsResponseAsync(UriIdentifier identifier, CancellationToken cancellationToken) {
Requires.NotNull(identifier, "identifier");
var result = await this.GetXrdsLocationAsync(identifier, cancellationToken);
if (result.Result == null) {
return new ResultWithSigningHost();
}
var response = await this.GetXrdsResponseAsync(identifier, result.Result, cancellationToken);
return new ResultWithSigningHost(response, result.SigningHost);
}
///
/// 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 async Task> GetXrdsLocationAsync(UriIdentifier identifier, CancellationToken cancellationToken) {
Requires.NotNull(identifier, "identifier");
using (var hostMetaResponse = await this.GetHostMetaAsync(identifier, cancellationToken)) {
if (hostMetaResponse.Result == null) {
return new ResultWithSigningHost();
}
using (var sr = new StreamReader(await hostMetaResponse.Result.Content.ReadAsStreamAsync())) {
string line = await sr.ReadLineAsync();
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.Result.RequestMessage.RequestUri);
return new ResultWithSigningHost(location, hostMetaResponse.SigningHost);
}
}
Logger.Yadis.WarnFormat("Could not find link to XRDS in host-meta document: {0}", hostMetaResponse.Result.RequestMessage.RequestUri);
return new ResultWithSigningHost();
}
}
///
/// 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 async Task> GetHostMetaAsync(UriIdentifier identifier, CancellationToken cancellationToken) {
Requires.NotNull(identifier, "identifier");
using (var httpClient = this.HostFactories.CreateHttpClient(identifier.IsDiscoverySecureEndToEnd, Yadis.IdentifierDiscoveryCachePolicy)) {
foreach (var hostMetaProxy in this.GetHostMetaLocations(identifier)) {
var hostMetaLocation = hostMetaProxy.GetProxy(identifier);
var response = await httpClient.GetAsync(hostMetaLocation, cancellationToken);
try {
if (response.IsSuccessStatusCode) {
Logger.Yadis.InfoFormat("Found host-meta for {0} at: {1}", identifier.Uri.Host, hostMetaLocation);
return new ResultWithSigningHost(response, hostMetaProxy.GetSigningHost(identifier));
} else {
Logger.Yadis.InfoFormat("Could not obtain host-meta for {0} from {1}", identifier.Uri.Host, hostMetaLocation);
response.Dispose();
}
} catch {
response.Dispose();
throw;
}
}
}
return new ResultWithSigningHost();
}
///
/// 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) {
Requires.NotNull(identifier, "identifier");
// 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) {
Requires.NotNullOrEmpty(proxyFormat, "proxyFormat");
Requires.NotNullOrEmpty(signingHostFormat, "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) {
Requires.NotNull(identifier, "identifier");
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) {
Requires.NotNull(identifier, "identifier");
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();
}
}
private struct ResultWithSigningHost : IDisposable {
internal ResultWithSigningHost(T result, string signingHost)
: this() {
this.Result = result;
this.SigningHost = signingHost;
}
public T Result { get; private set; }
public string SigningHost { get; private set; }
public void Dispose() {
var disposable = this.Result as IDisposable;
disposable.DisposeIfNotNull();
}
}
}
}