Skip to content

Commit 30ca71f

Browse files
authored
Merge pull request #115 from popicka70/QR2
Refresh in NuGet
2 parents 18face2 + 1c9466a commit 30ca71f

2 files changed

Lines changed: 164 additions & 4 deletions

File tree

Lines changed: 140 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,38 +1,174 @@
11
using Microsoft.AspNetCore.Authentication;
2+
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
23
using Microsoft.AspNetCore.Http;
34
using Microsoft.Extensions.Logging;
5+
using Microsoft.Extensions.Options;
6+
using System.Net.Http.Headers;
7+
using System.Text.Json;
48

59
namespace MrWho.ClientAuth.M2M;
610

711
/// <summary>
812
/// Delegating handler that forwards the current authenticated user's access token (server-side scenarios).
13+
/// Supports optional automatic refresh (refresh_token) and automatic OIDC challenge on downstream 401.
914
/// </summary>
1015
internal sealed class MrWhoUserAccessTokenHandler : DelegatingHandler
1116
{
1217
private readonly IHttpContextAccessor _httpContextAccessor;
1318
private readonly ILogger<MrWhoUserAccessTokenHandler> _logger;
19+
private readonly IAuthenticationSchemeProvider _schemeProvider;
20+
private readonly IOptionsMonitor<OpenIdConnectOptions> _oidcOptions;
21+
private readonly IHttpClientFactory _httpClientFactory;
22+
private readonly MrWhoUserAccessTokenHandlerOptions _options;
1423

15-
public MrWhoUserAccessTokenHandler(IHttpContextAccessor httpContextAccessor, ILogger<MrWhoUserAccessTokenHandler> logger)
24+
public MrWhoUserAccessTokenHandler(
25+
IHttpContextAccessor httpContextAccessor,
26+
ILogger<MrWhoUserAccessTokenHandler> logger,
27+
IAuthenticationSchemeProvider schemeProvider,
28+
IOptionsMonitor<OpenIdConnectOptions> oidcOptions,
29+
IOptions<MrWhoUserAccessTokenHandlerOptions> options,
30+
IHttpClientFactory httpClientFactory)
1631
{
1732
_httpContextAccessor = httpContextAccessor;
1833
_logger = logger;
34+
_schemeProvider = schemeProvider;
35+
_oidcOptions = oidcOptions;
36+
_httpClientFactory = httpClientFactory;
37+
_options = options.Value;
1938
}
2039

2140
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
2241
{
2342
var ctx = _httpContextAccessor.HttpContext;
2443
if (ctx?.User?.Identity?.IsAuthenticated == true)
2544
{
26-
var accessToken = await ctx.GetTokenAsync("access_token");
45+
string? accessToken;
46+
if (_options.EnableAutomaticRefresh)
47+
{
48+
accessToken = await EnsureFreshAccessTokenAsync(ctx, cancellationToken);
49+
}
50+
else
51+
{
52+
accessToken = await ctx.GetTokenAsync("access_token");
53+
}
54+
2755
if (!string.IsNullOrWhiteSpace(accessToken))
2856
{
29-
request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", accessToken);
57+
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
3058
}
3159
else
3260
{
3361
_logger.LogDebug("User authenticated but no access token present in session.");
3462
}
3563
}
36-
return await base.SendAsync(request, cancellationToken);
64+
65+
var response = await base.SendAsync(request, cancellationToken);
66+
67+
if (_options.ChallengeOnUnauthorized && response.StatusCode == System.Net.HttpStatusCode.Unauthorized && ctx?.User?.Identity?.IsAuthenticated == true)
68+
{
69+
if (!ctx.Response.HasStarted)
70+
{
71+
_logger.LogInformation("Downstream API returned 401 for user {Sub}. Triggering OIDC challenge.", ctx.User.FindFirst("sub")?.Value);
72+
await ctx.ChallengeAsync();
73+
}
74+
}
75+
76+
return response;
77+
}
78+
79+
private async Task<string?> EnsureFreshAccessTokenAsync(HttpContext ctx, CancellationToken ct)
80+
{
81+
var accessToken = await ctx.GetTokenAsync("access_token");
82+
var expiresAtStr = await ctx.GetTokenAsync("expires_at");
83+
if (!DateTimeOffset.TryParse(expiresAtStr, out var expiresAtUtc))
84+
{
85+
return accessToken;
86+
}
87+
88+
if (DateTimeOffset.UtcNow.Add(_options.RefreshSkew) < expiresAtUtc)
89+
{
90+
return accessToken;
91+
}
92+
93+
var refreshToken = await ctx.GetTokenAsync("refresh_token");
94+
if (string.IsNullOrWhiteSpace(refreshToken))
95+
{
96+
_logger.LogDebug("Access token near/at expiry but no refresh_token available.");
97+
return accessToken;
98+
}
99+
100+
try
101+
{
102+
var (cookieScheme, oidcScheme) = await ResolveSchemesAsync();
103+
var oidc = _oidcOptions.Get(oidcScheme);
104+
var authority = oidc.Authority?.TrimEnd('/') ?? string.Empty;
105+
if (string.IsNullOrEmpty(authority))
106+
{
107+
_logger.LogWarning("Cannot refresh token: OIDC authority missing.");
108+
return accessToken;
109+
}
110+
111+
var tokenEndpoint = $"{authority}/connect/token"; // standard endpoint
112+
var client = oidc.BackchannelHttpHandler != null
113+
? new HttpClient(oidc.BackchannelHttpHandler, disposeHandler: false)
114+
: _httpClientFactory.CreateClient();
115+
116+
var form = new Dictionary<string, string>
117+
{
118+
["grant_type"] = "refresh_token",
119+
["refresh_token"] = refreshToken
120+
};
121+
if (!string.IsNullOrEmpty(oidc.ClientId)) form["client_id"] = oidc.ClientId;
122+
if (!string.IsNullOrEmpty(oidc.ClientSecret)) form["client_secret"] = oidc.ClientSecret;
123+
124+
using var req = new HttpRequestMessage(HttpMethod.Post, tokenEndpoint) { Content = new FormUrlEncodedContent(form) };
125+
using var resp = await client.SendAsync(req, ct);
126+
if (!resp.IsSuccessStatusCode)
127+
{
128+
var body = await resp.Content.ReadAsStringAsync(ct);
129+
_logger.LogWarning("Refresh token request failed: {Status} {Body}", (int)resp.StatusCode, body);
130+
return accessToken;
131+
}
132+
133+
var json = await resp.Content.ReadAsStringAsync(ct);
134+
using var doc = JsonDocument.Parse(json);
135+
var newAccessToken = doc.RootElement.TryGetProperty("access_token", out var atEl) ? atEl.GetString() : null;
136+
var newRefreshToken = doc.RootElement.TryGetProperty("refresh_token", out var rtEl) ? rtEl.GetString() : refreshToken;
137+
var expiresIn = doc.RootElement.TryGetProperty("expires_in", out var expEl) ? expEl.GetInt32() : 3600;
138+
if (string.IsNullOrEmpty(newAccessToken))
139+
{
140+
_logger.LogWarning("Refresh response missing access_token.");
141+
return accessToken;
142+
}
143+
144+
var newExpiresAt = DateTimeOffset.UtcNow.AddSeconds(expiresIn);
145+
146+
var authResult = await ctx.AuthenticateAsync(cookieScheme);
147+
if (authResult.Succeeded && authResult.Properties != null)
148+
{
149+
authResult.Properties.UpdateTokenValue("access_token", newAccessToken);
150+
authResult.Properties.UpdateTokenValue("refresh_token", newRefreshToken);
151+
authResult.Properties.UpdateTokenValue("expires_at", newExpiresAt.ToString("o"));
152+
await ctx.SignInAsync(cookieScheme, authResult.Principal!, authResult.Properties);
153+
_logger.LogDebug("Refreshed user access token; new expiry {Expiry}", newExpiresAt);
154+
return newAccessToken;
155+
}
156+
_logger.LogWarning("Failed to persist refreshed token (scheme {Scheme}).", cookieScheme);
157+
return newAccessToken;
158+
}
159+
catch (Exception ex)
160+
{
161+
_logger.LogError(ex, "Error refreshing user access token");
162+
return accessToken;
163+
}
164+
}
165+
166+
private async Task<(string cookieScheme, string oidcScheme)> ResolveSchemesAsync()
167+
{
168+
var defaultAuthenticate = await _schemeProvider.GetDefaultAuthenticateSchemeAsync();
169+
var defaultChallenge = await _schemeProvider.GetDefaultChallengeSchemeAsync();
170+
var cookie = defaultAuthenticate?.Name ?? MrWhoClientAuthDefaults.CookieScheme;
171+
var oidc = defaultChallenge?.Name ?? MrWhoClientAuthDefaults.OpenIdConnectScheme;
172+
return (cookie, oidc);
37173
}
38174
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
namespace MrWho.ClientAuth.M2M;
2+
3+
/// <summary>
4+
/// Options controlling behavior of <see cref="MrWhoUserAccessTokenHandler"/>.
5+
/// </summary>
6+
public sealed class MrWhoUserAccessTokenHandlerOptions
7+
{
8+
/// <summary>
9+
/// When true (default) the handler will attempt to refresh the user's access token automatically
10+
/// using the refresh_token (if present) when the token is within a skew of expiry.
11+
/// </summary>
12+
public bool EnableAutomaticRefresh { get; set; } = true;
13+
14+
/// <summary>
15+
/// When true (default) a downstream 401 response will trigger an OpenID Connect challenge (redirect to login)
16+
/// if the current HttpContext has an authenticated principal.
17+
/// </summary>
18+
public bool ChallengeOnUnauthorized { get; set; } = true;
19+
20+
/// <summary>
21+
/// Skew window before expiry where refresh will be attempted. Default: 1 minute.
22+
/// </summary>
23+
public TimeSpan RefreshSkew { get; set; } = TimeSpan.FromMinutes(1);
24+
}

0 commit comments

Comments
 (0)