wwauth/Google.Solutions.WWAuth/Adapters/Adfs/AdfsOidcAdapter.cs (197 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.Auth.OAuth2.Responses;
using Google.Apis.Logging;
using Google.Apis.Util;
using Google.Solutions.WWAuth.Data;
using Newtonsoft.Json;
using System;
using System.Collections.Generic;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Threading;
using System.Threading.Tasks;
namespace Google.Solutions.WWAuth.Adapters.Adfs
{
/// <summary>
/// Adapter for acquiring tokens using OIDC and the
/// client credentials grant.
/// </summary>
internal class AdfsOidcAdapter : AdfsAdapterBase
{
/// <summary>
/// AD FS Client ID, can have any format.
/// </summary>
public string ClientId { get; }
/// <summary>
/// AD FS Web API. Typed as string (and not Uri) to prevent
/// canonicaliuation, which might break the STS token exchange.
/// </summary>
public string Resource { get; }
/// <summary>
/// URI to OIDC configuration/metadata.
/// </summary>
public Uri OidcConfigurationUrl
=> new Uri(this.IssuerUrl, ".well-known/openid-configuration");
/// <summary>
/// SPN to verify in pre-flight checks.
/// </summary>
protected override string ServicePrincipalName
=> $"HTTP/{this.IssuerUrl.Host}";
public AdfsOidcAdapter(
Uri issuerUrl,
string clientId,
string resource,
ILogger logger) : base(issuerUrl, logger)
{
this.ClientId = clientId.ThrowIfNull(nameof(clientId));
this.Resource = resource.ThrowIfNull(nameof(resource));
}
private async Task<OidcConfiguration> FetchOidcConfigurationAsync(
CancellationToken cancellationToken)
{
try
{
this.Logger.Info(
"Fetching OpenID Connect configuration from '{0}'",
this.OidcConfigurationUrl);
using (var client = new HttpClient())
{
client.DefaultRequestHeaders.UserAgent.Add(
new ProductInfoHeaderValue(
UserAgent.Default.Name,
UserAgent.Default.Version));
using (var response = await client.GetAsync(
this.OidcConfigurationUrl,
cancellationToken)
.ConfigureAwait(false))
{
response.EnsureSuccessStatusCode();
return JsonConvert.DeserializeObject<OidcConfiguration>(
await response.Content
.ReadAsStringAsync()
.ConfigureAwait(false));
}
}
}
catch (JsonException e)
{
throw new TokenAcquisitionException(
$"The OpenID Connect configuration located at '{this.OidcConfigurationUrl}' " +
$"contains invalid data. Verify that the issuer URL '{this.IssuerUrl}' " +
"is correct and that the URL is accessible.",
e);
}
catch (HttpRequestException e)
{
throw new TokenAcquisitionException(
$"The OpenID Connect configuration located at '{this.OidcConfigurationUrl}' " +
$"is not available. Verify that the issuer URL '{this.IssuerUrl}' " +
"is correct and that the URL is accessible.",
e);
}
}
protected override async Task<ISubjectToken> AcquireTokenCoreAsync(
CancellationToken cancellationToken)
{
//
// Fetch the OIDC configuration. This implicitly validates the
// issuer URL and provides us the token endpoint.
//
var configuration = await FetchOidcConfigurationAsync(cancellationToken)
.ConfigureAwait(false);
this.Logger.Info("Using token endpoint '{0}'", configuration.TokenEndpoint);
//
// Request a token from the token endpoint.
//
using (var handler = new HttpClientHandler())
{
handler.Credentials = this.Credential;
using (var client = new HttpClient(handler))
{
this.Logger.Info(
"Acquiring OIDC token for client ID '{0}' " +
"and resource '{1}' using IWA",
this.ClientId,
this.Resource);
//
// AD FS occasionally fails requests for no good reason, so use
// a backoff/retry loop.
//
var backoff = new ExponentialBackOff();
for (int retries = 0; ; retries++)
{
var request = new HttpRequestMessage(HttpMethod.Post, configuration.TokenEndpoint)
{
Content = new FormUrlEncodedContent(new Dictionary<string, string>
{
{ "client_id", this.ClientId },
{ "resource", this.Resource },
{ "grant_type", "client_credentials" },
{ "use_windows_client_authentication", "true" }, // Use IWA, see [OS-OAPX].
{ "scope", "openid" }
})
};
//
// Set a custom user-agent so that AD FS lets us use IWA.
//
request.Headers.Add("User-Agent", AdfsAdapterBase.IwaUserAgent);
using (var response = await client.SendAsync(
request,
HttpCompletionOption.ResponseHeadersRead,
cancellationToken)
.ConfigureAwait(false))
{
var responseType = response.Content?.Headers?.ContentType?.MediaType;
if (responseType == "application/json" &&
response.StatusCode == HttpStatusCode.OK)
{
//
// Use the existing TokenResponse class to parse
// the response (and handle errors).
//
this.Logger.Info("Acquiring token succeeded");
return JsonWebToken.FromResponse(
await TokenResponse.FromHttpResponseAsync(
response,
SystemClock.Default,
new NullLogger())
.ConfigureAwait(false));
}
else if (responseType == "application/json")
{
//
// Request failed, but we got a proper OAuth-formatted error.
//
var error = JsonConvert.DeserializeObject<TokenErrorResponse>(
await response.Content
.ReadAsStringAsync()
.ConfigureAwait(false));
throw new TokenAcquisitionException(
$"Authentication failed: {error.ErrorDescription}\n" +
$"Error code: {error.Error}\n" +
$"HTTP Status: {response.StatusCode}");
}
else if (responseType == "text/html")
{
//
// AD FS returns some errors in HTML format.
//
var responseBody = await response.Content
.ReadAsStringAsync()
.ConfigureAwait(false);
this.Logger.Error(
"Received unexpected response of type {0} from {1}: {2}",
responseType,
configuration.TokenEndpoint,
responseBody);
var html = new HtmlResponse(responseBody);
if (html.Error != null)
{
throw new TokenAcquisitionException(
$"Authentication failed: {html.Error}. See logs for " +
"full response message");
}
else
{
throw new TokenAcquisitionException(
"Authentication failed. The server sent an unexpected response of type " +
$"{responseType}. See logs for " +
"full response message");
}
}
else if (response.StatusCode == HttpStatusCode.Unauthorized)
{
throw new TokenAcquisitionException(
$"Authentication failed. Verify that the client '{this.ClientId}' " +
$"exists and is configured to allow access to the current AD user.\n\n" +
"If AD FS is deployed behind a load balancer, verify that the " +
"token binding settings (ExtendedProtectionTokenCheck) are compatible " +
"with your load balancer setup.");
}
else if ((response.StatusCode == HttpStatusCode.BadRequest ||
response.StatusCode == (HttpStatusCode)429) &&
retries < backoff.MaxNumOfRetries)
{
//
// Retry.
//
this.Logger.Warning("Received Bad Request response, retrying");
await Task
.Delay(backoff.DeltaBackOff)
.ConfigureAwait(false);
}
else
{
//
// Unspecific error.
//
response.EnsureSuccessStatusCode();
throw new TokenAcquisitionException(
$"Authentication failed: {response.StatusCode}");
}
}
}
}
}
}
public class OidcConfiguration
{
[JsonProperty("token_endpoint")]
public string TokenEndpoint { get; set; }
}
}
}