Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions pwiz_tools/Shared/CommonMsData/CommonMsData.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,7 @@
</Compile>
<Compile Include="RemoteApi\WatersConnect\WatersConnectSession.cs" />
<Compile Include="RemoteApi\WatersConnect\WatersConnectSessionAcquisitionMethod.cs" />
<Compile Include="RemoteApi\WatersConnect\WatersConnectTokenResponse.cs" />
<Compile Include="RemoteApi\WatersConnect\WatersConnectUrl.cs" />
<Compile Include="CommonMsDataResources.Designer.cs">
<AutoGen>True</AutoGen>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,6 @@
using System.Xml;
using System.Xml.Linq;
using System.Xml.Serialization;
using IdentityModel.Client;
using Newtonsoft.Json.Linq;
using pwiz.Common.SystemUtil;

Expand Down Expand Up @@ -74,7 +73,7 @@ public class WatersConnectAccount : RemoteAccount

public class TokenCacheEntry
{
public TokenResponse TokenResponse { get; set; }
public WatersConnectTokenResponse TokenResponse { get; set; }
public DateTime ExpirationDateTime { get; set; }
}

Expand Down Expand Up @@ -202,7 +201,7 @@ public string GetFoldersUrl()

private string IdentityConnectEndpoint => @"/connect/token";

public TokenResponse Authenticate()
public WatersConnectTokenResponse Authenticate()
{
// First check the cache for a valid token
if (_authenticationTokens.TryGetValue(this, out var tokenCacheEntry) && tokenCacheEntry.ExpirationDateTime > DateTime.UtcNow)
Expand Down Expand Up @@ -236,7 +235,7 @@ public TokenResponse Authenticate()
if (newToken.IsError)
{
AuthenticationException ex;
if (newToken.ErrorType == ResponseErrorType.Exception)
if (newToken.ErrorType == TokenErrorType.Exception)
ex = new AuthenticationException(newToken.Error);
else
ex = new AuthenticationException(string.Format(CultureInfo.CurrentCulture,
Expand All @@ -254,14 +253,14 @@ public TokenResponse Authenticate()
/// POSTs a token request to the identity server and returns the parsed response. Client
/// credentials go in an HTTP Basic authorization header with each half URL-escaped per
/// RFC 6749 section 2.3.1, matching the wire format of the IdentityModel TokenClient this
/// replaced. Every failure is returned as an error <see cref="TokenResponse"/> - the same
/// contract TokenClient had - so callers route all failures through the
/// replaced. Every failure is returned as an error <see cref="WatersConnectTokenResponse"/> -
/// the same contract TokenClient had - so callers route all failures through the
/// authentication-error path: a 400 is an OAuth protocol error whose JSON body carries
/// error/error_description; any other HTTP failure becomes an HTTP-error response (IsError
/// true even when the body is a proxy's HTML page); a transport or URL-format exception
/// becomes an exception-type response.
/// </summary>
private TokenResponse RequestToken(NameValueCollection form)
private WatersConnectTokenResponse RequestToken(NameValueCollection form)
{
try
{
Expand All @@ -271,17 +270,17 @@ private TokenResponse RequestToken(NameValueCollection form)
EscapeClientCredential(ClientId) + @":" + EscapeClientCredential(ClientSecret))));
httpClient.AddHeader(@"Accept", @"application/json");
var raw = Encoding.UTF8.GetString(httpClient.UploadValues(requestUri, @"POST", form));
return new TokenResponse(raw);
return WatersConnectTokenResponse.FromJson(raw);
}
catch (NetworkRequestException ex)
{
if (ex.StatusCode == HttpStatusCode.BadRequest && !string.IsNullOrEmpty(ex.ResponseBody))
return new TokenResponse(ex.ResponseBody);
return new TokenResponse(ex.StatusCode ?? HttpStatusCode.ServiceUnavailable, ex.Message, ex.ResponseBody);
return WatersConnectTokenResponse.FromJson(ex.ResponseBody);
return WatersConnectTokenResponse.FromHttpError(ex.StatusCode ?? HttpStatusCode.ServiceUnavailable, ex.Message, ex.ResponseBody);
}
catch (Exception ex)
{
return new TokenResponse(ex);
return WatersConnectTokenResponse.FromException(ex);
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
/*
* Original author: Brendan MacLean <brendanx .at. uw.edu>,
* MacCoss Lab, Department of Genome Sciences, UW
* AI assistance: Claude Code (Claude Opus 5) <noreply .at. anthropic.com>
*
* Copyright 2026 University of Washington - Seattle, WA
*
* Licensed 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 System;
using System.Net;
using Newtonsoft.Json;

namespace pwiz.CommonMsData.RemoteApi.WatersConnect
{
/// <summary>
/// How a token request failed, when it did.
/// </summary>
public enum TokenErrorType
{
None,
/// <summary>The identity server answered with an OAuth error body.</summary>
Protocol,
/// <summary>The server answered, but not with a usable token response.</summary>
Http,
/// <summary>The request never produced a response at all.</summary>
Exception
}

/// <summary>
/// The identity server's answer to a token request, parsed from its JSON body.
/// <para>waters_connect makes its own token request through
/// <see cref="pwiz.Common.SystemUtil.HttpClientWithProgress"/> and classifies its own failures,
/// so the only thing IdentityModel was still providing here was this shape. Owning it keeps
/// this code compiling the same way whichever IdentityModel version is referenced - 3.9 and 7
/// disagree about whether the equivalent type can be constructed at all - and follows the same
/// pattern as the Ardia response types alongside it.</para>
/// </summary>
public class WatersConnectTokenResponse
{
private WatersConnectTokenResponse() { }

[JsonProperty(@"access_token")]
public string AccessToken { get; private set; }

[JsonProperty(@"refresh_token")]
public string RefreshToken { get; private set; }

[JsonProperty(@"expires_in")]
public int ExpiresIn { get; private set; }

[JsonProperty(@"error")]
public string Error { get; private set; }

[JsonProperty(@"error_description")]
public string ErrorDescription { get; private set; }

/// <summary>
/// The response body verbatim. <see cref="WatersConnectAccount.HandleAuthenticationException"/>
/// re-parses this to tell invalid_scope, invalid_client and invalid_grant apart, so it must
/// stay exactly what the server sent.
/// </summary>
[JsonIgnore]
public string Raw { get; private set; }

[JsonIgnore]
public TokenErrorType ErrorType { get; private set; }

[JsonIgnore]
public bool IsError => ErrorType != TokenErrorType.None;

/// <summary>
/// Parses a token response body. A body that is not JSON, or that carries no access token,
/// is an error rather than an exception - the caller routes every failure through the
/// authentication-error path, and a proxy answering 200 with an HTML page must not throw
/// past it.
/// </summary>
public static WatersConnectTokenResponse FromJson(string json)
{
WatersConnectTokenResponse response;
try
{
response = JsonConvert.DeserializeObject<WatersConnectTokenResponse>(json)
?? new WatersConnectTokenResponse();
}
catch (Exception e)
{
return new WatersConnectTokenResponse
{
Raw = json, ErrorType = TokenErrorType.Exception, Error = e.Message
};
}
Comment on lines +97 to +103

response.Raw = json;
if (!string.IsNullOrEmpty(response.Error))
response.ErrorType = TokenErrorType.Protocol;
else if (string.IsNullOrEmpty(response.AccessToken))
response.ErrorType = TokenErrorType.Http;
return response;
}

/// <summary>
/// An HTTP failure carrying no usable OAuth error body. <paramref name="body"/> is kept as
/// <see cref="Raw"/> even when it is a proxy's HTML, so the caller can show it.
/// </summary>
public static WatersConnectTokenResponse FromHttpError(HttpStatusCode statusCode, string reason, string body)
{
return new WatersConnectTokenResponse
{
Raw = body,
ErrorType = TokenErrorType.Http,
Error = statusCode.ToString(),
ErrorDescription = reason
};
}

/// <summary>
/// A request that never reached the server, or whose failure carried no response at all.
/// </summary>
public static WatersConnectTokenResponse FromException(Exception exception)
{
return new WatersConnectTokenResponse
{
ErrorType = TokenErrorType.Exception,
Error = exception.Message
};
}
}
}
4 changes: 3 additions & 1 deletion scripts/misc/vcs_trigger_and_paths_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,13 +104,15 @@
("scripts/.*", targets['All']),
("pwiz_tools/BiblioSpec/.*", merge(targets['Core'], targets['Skyline'], targets['Container'])),
("pwiz_tools/Bumbershoot/.*", targets['Bumbershoot']),
("pwiz_tools/Skyline/Model/Results/RemoteApi/.*", merge(targets['SkylineWithTestConnected'], targets['Container'])),
("pwiz_tools/Skyline/TestConnected/.*", merge(targets['SkylineWithTestConnected'], targets['Container'])),
("pwiz_tools/Skyline/.*Ardia.*", merge(targets['SkylineWithTestConnected'], targets['Container'])),
("pwiz_tools/Skyline/.*Koina.*", merge(targets['SkylineWithTestConnected'], targets['Container'])),
("pwiz_tools/Skyline/.*Panorama.*", merge(targets['SkylineWithTestConnected'], targets['Container'])),
("pwiz_tools/Skyline/.*Unifi.*", merge(targets['SkylineWithTestConnected'], targets['Container'])),
("pwiz_tools/Skyline/.*WatersConnect.*", merge(targets['SkylineWithTestConnected'], targets['Container'])),
("pwiz_tools/Skyline/.*DataSource.*", merge(targets['SkylineWithTestConnected'], targets['Container'])),
("pwiz_tools/Skyline/.*", merge(targets['Skyline'], targets['Container'])),
("pwiz_tools/Shared/CommonMsData/RemoteApi/.*", merge(targets['SkylineWithTestConnected'], targets['Container'])),
("pwiz_tools/Shared/.*", merge(targets['Skyline'], targets['BumbershootRelease'], targets['Container'])),
("pwiz_tools/Osprey/.*", targets['OspreyWindowsNet']),
("pwiz_tools/.*", targets['All']),
Expand Down
Loading