wwauth/Google.Solutions.WWAuth/Data/Saml2/AuthenticationResponse.cs (155 lines of code) (raw):
//
// Copyright 2022 Google LLC
//
// Licensed to the Apache Software Foundation (ASF) under one
// or more contributor license agreements. See the NOTICE file
// distributed with this work for additional information
// regarding copyright ownership. The ASF licenses this file
// to you under the Apache License, Version 2.0 (the
// "License"); you may not use this file except in compliance
// with the License. You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing,
// software distributed under the License is distributed on an
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
// KIND, either express or implied. See the License for the
// specific language governing permissions and limitations
// under the License.
//
using Google.Apis.Util;
using Google.Solutions.WWAuth.Util;
using System;
using System.Collections.Generic;
using System.IdentityModel.Tokens;
using System.IO;
using System.Linq;
using System.Text;
using System.Xml;
using System.Xml.Serialization;
namespace Google.Solutions.WWAuth.Data.Saml2
{
/// <summary>
/// Saml 2.0 authentication response, possibly containing
/// an assertion.
/// </summary>
internal abstract class AuthenticationResponse : ISubjectToken
{
public SubjectTokenType Type => SubjectTokenType.Saml2;
public string Value { get; }
public string Issuer { get; }
public abstract bool IsEncrypted { get; }
public abstract string Audience { get; }
public abstract DateTimeOffset? Expiry { get; }
public abstract IDictionary<string, object> Attributes { get; }
protected AuthenticationResponse(
string issuer,
string rawValue)
{
this.Issuer = issuer.ThrowIfNullOrEmpty(nameof(issuer));
this.Value = rawValue.ThrowIfNullOrEmpty(nameof(rawValue));
}
public static AuthenticationResponse Parse(string encodedResponse)
{
try
{
var xml = Encoding.UTF8.GetString(
Convert.FromBase64String(encodedResponse));
var deserializer = new XmlSerializer(typeof(Saml2Schema.Response));
var response = (Saml2Schema.Response)deserializer.Deserialize(
new StringReader(xml));
if (response.Version != "2.0")
{
throw new InvalidSamlResponseException(
"Unsupported SAML version");
}
else if (string.IsNullOrEmpty(response.ID))
{
throw new InvalidSamlResponseException(
"Malformed SAML response: Missing required attribute: ID");
}
else if (string.IsNullOrEmpty(response.Issuer))
{
throw new InvalidSamlResponseException(
"Malformed SAML response: Missing required attribute: Issuer");
}
else if (response.IssueInstant == DateTime.MinValue)
{
throw new InvalidSamlResponseException(
"Malformed SAML response: Missing required attribute: IssueInstant");
}
else if (string.IsNullOrEmpty(response.Destination))
{
throw new InvalidSamlResponseException(
"Malformed SAML response: Missing required attribute: Destination");
}
else if (response.Status?.StatusCode?.Value == "urn:oasis:names:tc:SAML:2.0:status:Responder" &&
string.IsNullOrEmpty(response.Status?.StatusMessage) &&
string.IsNullOrEmpty(response.Status.StatusCode?.StatusCode?.Value))
{
throw new InvalidSamlResponseException(
$"Server rejected request, possible because of mismatched signing settings");
}
else if (response.Status?.StatusCode?.Value !=
"urn:oasis:names:tc:SAML:2.0:status:Success")
{
throw new InvalidSamlResponseException(
$"Request failed with status code {response.Status?.StatusCode?.Value}\n\n" +
$"Details: {response.Status?.StatusMessage ?? "-"}\n" +
$"Detail status code {response.Status.StatusCode?.StatusCode?.Value ?? "-"}");
}
//
// There should be either an <Assertion/> or <EncryptedAssertion/> element,
// but we don't know which.
//
var assertionElement = response.Assertion
.EnsureNotNull()
.FirstOrDefault(n => n.NamespaceURI == "urn:oasis:names:tc:SAML:2.0:assertion");
switch (assertionElement.Name)
{
case "Assertion":
//
// Response contains non-encrypted assertion. We can parse
// this to extract attributes, etc.
//
using (var assertionReader = new XmlNodeReader(assertionElement))
{
var tokenHandler = new Saml2SecurityTokenHandler()
{
Configuration = new SecurityTokenHandlerConfiguration()
};
tokenHandler.Configuration.CertificateValidationMode =
System.ServiceModel.Security.X509CertificateValidationMode.None;
var assertion = new Assertion(
(Saml2SecurityToken)tokenHandler.ReadToken(assertionReader),
null);
return new Saml2ResponseWithPlaintextAssertion(
encodedResponse,
assertion);
}
case "EncryptedAssertion":
//
// Response contains an encrypted assertion. We can't do
// much with that.
//
return new Saml2ResponseWithEncryptedAssertion(
response.Issuer,
encodedResponse);
default:
throw new InvalidSamlResponseException(
$"SAML Response does not contain an assertion: {assertionElement}");
}
}
catch (InvalidOperationException e)
{
throw new InvalidSamlResponseException("Failed to parse SAML respose", e);
}
}
/// <summary>
/// SAML 2.0 response with an embedded encrypted SAML assertion.
/// </summary>
private class Saml2ResponseWithEncryptedAssertion : AuthenticationResponse
{
public override bool IsEncrypted => true;
public override string Audience => null;
public override DateTimeOffset? Expiry => null;
public override IDictionary<string, object> Attributes
=> new Dictionary<string, object>();
public Saml2ResponseWithEncryptedAssertion(
string issuer,
string rawValue)
: base(issuer, rawValue)
{
}
}
/// <summary>
/// SAML 2.0 response with an embedded plaintext SAML assertion. The
/// assertion may or may not be signed, we don't care.
/// </summary>
private class Saml2ResponseWithPlaintextAssertion : AuthenticationResponse
{
private readonly Assertion assertion;
public override bool IsEncrypted => false;
public override string Audience => this.assertion.Audience;
public override DateTimeOffset? Expiry => this.assertion.Expiry;
public override IDictionary<string, object> Attributes => this.assertion.Attributes;
public Saml2ResponseWithPlaintextAssertion(
string rawValue,
Assertion assertion)
: base(assertion.Issuer, rawValue)
{
this.assertion = assertion.ThrowIfNull(nameof(assertion));
}
}
}
public class InvalidSamlResponseException : Exception
{
public InvalidSamlResponseException(string message) : base(message)
{
}
public InvalidSamlResponseException(
string message,
Exception innerException) : base(message, innerException)
{
}
}
}