Skip to content
Merged
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
24 changes: 24 additions & 0 deletions EXAMPLES.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
- [Login and Logout](#login-and-logout)
- [Scopes](#scopes)
- [Calling an API](#calling-an-api)
- [Configuring the refresh leeway](#configuring-the-refresh-leeway)
- [Organizations](#organizations)
- [Extra parameters](#extra-parameters)
- [Roles](#roles)
Expand Down Expand Up @@ -140,6 +141,29 @@ public void ConfigureServices(IServiceCollection services)
}
```

#### Configuring the refresh leeway

When refresh tokens are enabled, the SDK refreshes the access token slightly *before* it actually expires, so a request in flight isn't left holding a token that lapses mid-call. This window defaults to **60 seconds**. You can tune it with `AccessTokenExpirationLeeway`:

```csharp
services
.AddAuth0WebAppAuthentication(options =>
{
options.Domain = Configuration["Auth0:Domain"];
options.ClientId = Configuration["Auth0:ClientId"];
options.ClientSecret = Configuration["Auth0:ClientSecret"];
})
.WithAccessToken(options =>
{
options.Audience = Configuration["Auth0:Audience"];
options.UseRefreshTokens = true;
// Refresh the access token up to 2 minutes before it expires.
options.AccessTokenExpirationLeeway = TimeSpan.FromSeconds(120);
});
```

A larger leeway refreshes more eagerly (fewer near-expiry tokens, more refresh calls); a smaller leeway refreshes later. The leeway only takes effect when `UseRefreshTokens` is enabled.

#### Detecting the absense of a refresh token

In the event where the API, defined in your Auth0 dashboard, isn't configured to [allow offline access](https://auth0.com/docs/get-started/dashboard/api-settings), or the user was already logged in before the use of refresh tokens was enabled (e.g. a user logs in a few minutes before the use of refresh tokens is deployed), it might be useful to detect the absense of a refresh token in order to react accordingly (e.g. log the user out locally and force them to re-login).
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
namespace Auth0.AspNetCore.Authentication
using System;

namespace Auth0.AspNetCore.Authentication
{
/// <summary>
/// Options used to configure the SDK when using Access Tokens
Expand All @@ -20,6 +22,14 @@ public class Auth0WebAppWithAccessTokenOptions
/// </summary>
public bool UseRefreshTokens { get; set; }

/// <summary>
/// The amount of time before an access token expires during which it is treated as
/// already expired, so that a refresh is triggered proactively rather than the token
/// lapsing mid-request. Only applies when <see cref="UseRefreshTokens"/> is enabled.
/// Defaults to 60 seconds.
/// </summary>
public TimeSpan AccessTokenExpirationLeeway { get; set; } = TimeSpan.FromSeconds(60);

/// <summary>
/// Events allowing you to hook into specific moments in the Auth0 middleware.
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,7 @@
var issuer = Utils.ToAuthority(resolvedIssuer ?? $"https://{options.Domain}/");
var sid = context.Principal?.FindFirst("sid")?.Value;

var isLoggedOut = await logoutTokenHandler.IsLoggedOutAsync(issuer, sid);

Check warning on line 171 in src/Auth0.AspNetCore.Authentication/AuthenticationBuilderExtensions.cs

View workflow job for this annotation

GitHub Actions / build (net7.0)

Possible null reference argument for parameter 'sid' in 'Task<bool> ILogoutTokenHandler.IsLoggedOutAsync(string issuer, string sid)'.

Check warning on line 171 in src/Auth0.AspNetCore.Authentication/AuthenticationBuilderExtensions.cs

View workflow job for this annotation

GitHub Actions / build (net7.0)

Possible null reference argument for parameter 'sid' in 'Task<bool> ILogoutTokenHandler.IsLoggedOutAsync(string issuer, string sid)'.

Check warning on line 171 in src/Auth0.AspNetCore.Authentication/AuthenticationBuilderExtensions.cs

View workflow job for this annotation

GitHub Actions / build (net6.0)

Possible null reference argument for parameter 'sid' in 'Task<bool> ILogoutTokenHandler.IsLoggedOutAsync(string issuer, string sid)'.

Check warning on line 171 in src/Auth0.AspNetCore.Authentication/AuthenticationBuilderExtensions.cs

View workflow job for this annotation

GitHub Actions / build (net6.0)

Possible null reference argument for parameter 'sid' in 'Task<bool> ILogoutTokenHandler.IsLoggedOutAsync(string issuer, string sid)'.

Check warning on line 171 in src/Auth0.AspNetCore.Authentication/AuthenticationBuilderExtensions.cs

View workflow job for this annotation

GitHub Actions / build (net8.0)

Possible null reference argument for parameter 'sid' in 'Task<bool> ILogoutTokenHandler.IsLoggedOutAsync(string issuer, string sid)'.

Check warning on line 171 in src/Auth0.AspNetCore.Authentication/AuthenticationBuilderExtensions.cs

View workflow job for this annotation

GitHub Actions / build (net8.0)

Possible null reference argument for parameter 'sid' in 'Task<bool> ILogoutTokenHandler.IsLoggedOutAsync(string issuer, string sid)'.

Check warning on line 171 in src/Auth0.AspNetCore.Authentication/AuthenticationBuilderExtensions.cs

View workflow job for this annotation

GitHub Actions / build (net10.0)

Possible null reference argument for parameter 'sid' in 'Task<bool> ILogoutTokenHandler.IsLoggedOutAsync(string issuer, string sid)'.

Check warning on line 171 in src/Auth0.AspNetCore.Authentication/AuthenticationBuilderExtensions.cs

View workflow job for this annotation

GitHub Actions / build (net10.0)

Possible null reference argument for parameter 'sid' in 'Task<bool> ILogoutTokenHandler.IsLoggedOutAsync(string issuer, string sid)'.

if (isLoggedOut)
{
Expand Down Expand Up @@ -199,8 +199,8 @@
{
var now = DateTimeOffset.Now;
var expiresAt = DateTimeOffset.Parse(context.Properties.Items[".Token.expires_at"]!);
var leeway = 60;
var difference = DateTimeOffset.Compare(expiresAt, now.AddSeconds(leeway));
var leeway = optionsWithAccessToken.AccessTokenExpirationLeeway;
var difference = DateTimeOffset.Compare(expiresAt, now.Add(leeway));
var isExpired = difference <= 0;

if (isExpired && !string.IsNullOrWhiteSpace(refreshToken))
Expand Down Expand Up @@ -262,7 +262,7 @@
{
if (oidcOptions.Configuration == null)
{
oidcOptions.Configuration = await oidcOptions.ConfigurationManager.GetConfigurationAsync(context.RequestAborted);

Check warning on line 265 in src/Auth0.AspNetCore.Authentication/AuthenticationBuilderExtensions.cs

View workflow job for this annotation

GitHub Actions / build (net7.0)

Dereference of a possibly null reference.

Check warning on line 265 in src/Auth0.AspNetCore.Authentication/AuthenticationBuilderExtensions.cs

View workflow job for this annotation

GitHub Actions / build (net7.0)

Dereference of a possibly null reference.

Check warning on line 265 in src/Auth0.AspNetCore.Authentication/AuthenticationBuilderExtensions.cs

View workflow job for this annotation

GitHub Actions / build (net6.0)

Dereference of a possibly null reference.

Check warning on line 265 in src/Auth0.AspNetCore.Authentication/AuthenticationBuilderExtensions.cs

View workflow job for this annotation

GitHub Actions / build (net6.0)

Dereference of a possibly null reference.

Check warning on line 265 in src/Auth0.AspNetCore.Authentication/AuthenticationBuilderExtensions.cs

View workflow job for this annotation

GitHub Actions / build (net8.0)

Dereference of a possibly null reference.

Check warning on line 265 in src/Auth0.AspNetCore.Authentication/AuthenticationBuilderExtensions.cs

View workflow job for this annotation

GitHub Actions / build (net8.0)

Dereference of a possibly null reference.

Check warning on line 265 in src/Auth0.AspNetCore.Authentication/AuthenticationBuilderExtensions.cs

View workflow job for this annotation

GitHub Actions / build (net10.0)

Dereference of a possibly null reference.

Check warning on line 265 in src/Auth0.AspNetCore.Authentication/AuthenticationBuilderExtensions.cs

View workflow job for this annotation

GitHub Actions / build (net10.0)

Dereference of a possibly null reference.
}

var additionalConfiguration = oidcOptions.Configuration.AdditionalData;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1433,6 +1433,111 @@ public async void Should_Not_Refresh_Access_Token_When_Not_Expired()
}
}

[Fact]
public async void Should_Refresh_Access_Token_When_Within_Configured_Leeway()
{
// The token is valid for 70s, which is beyond the default 60s leeway and would
// normally NOT trigger a refresh (see Should_Not_Refresh_Access_Token_When_Not_Expired).
// Configuring a 120s leeway makes the SDK treat it as expiring imminently and refresh it.
var nonce = "";
var configuration = TestConfiguration.GetConfiguration();
var domain = configuration["Auth0:Domain"];
var clientId = configuration["Auth0:ClientId"];

var mockHandler = new OidcMockBuilder()
.MockOpenIdConfig()
.MockJwks()
.MockToken(() => JwtUtils.GenerateToken(1, $"https://{domain}/", clientId, null, nonce, DateTime.UtcNow.AddSeconds(70)), (me) => me.HasGrantType("authorization_code"))
.MockToken(() => JwtUtils.GenerateToken(1, $"https://{domain}/", clientId, null, null, DateTime.UtcNow.AddSeconds(70)), (me) => me.HasGrantType("refresh_token"), 70, true, HttpStatusCode.OK, "456_ROTATED")
.Build();

using (var server = TestServerBuilder.CreateServer(opts =>
{
opts.ClientSecret = "123";
opts.Backchannel = new HttpClient(mockHandler.Object);
}, opts =>
{
opts.Audience = "123";
opts.UseRefreshTokens = true;
opts.AccessTokenExpirationLeeway = TimeSpan.FromSeconds(120);
}))
{
using (var client = server.CreateClient())
{
var loginResponse = (await client.SendAsync($"{TestServerBuilder.Host}/{TestServerBuilder.Login}"));
var setCookie = Assert.Single(loginResponse.Headers, h => h.Key == "Set-Cookie");

var queryParameters = UriUtils.GetQueryParams(loginResponse.Headers.Location);
nonce = queryParameters["nonce"];
var state = queryParameters["state"];

var message = new HttpRequestMessage(HttpMethod.Get, $"{TestServerBuilder.Host}/{TestServerBuilder.Callback}?state={state}&nonce={nonce}&code=123");
var callbackResponse = (await client.SendAsync(message, setCookie.Value));

callbackResponse.Headers.Location.OriginalString.Should().Be("/");

var response = await client.SendAsync($"{TestServerBuilder.Host}/{TestServerBuilder.Process}", callbackResponse.Headers.GetValues("Set-Cookie"));
var content = JObject.Parse(response.Content.ReadAsStringAsync().Result);

mockHandler.Verify();

content.GetValue("RefreshToken").Value<string>().Should().Be("456_ROTATED");
}
}
}

[Fact]
public async void Should_Not_Refresh_Access_Token_When_Outside_Configured_Leeway()
{
// The token is valid for 40s, which is within the default 60s leeway and would
// normally trigger a refresh. Configuring a smaller 5s leeway leaves it untouched.
// No refresh_token grant is mocked, so any refresh attempt would surface as a failure.
var nonce = "";
var configuration = TestConfiguration.GetConfiguration();
var domain = configuration["Auth0:Domain"];
var clientId = configuration["Auth0:ClientId"];

var mockHandler = new OidcMockBuilder()
.MockOpenIdConfig()
.MockJwks()
.MockToken(() => JwtUtils.GenerateToken(1, $"https://{domain}/", clientId, null, nonce, DateTime.UtcNow.AddSeconds(40)), (me) => me.HasGrantType("authorization_code"), 40)
.Build();

using (var server = TestServerBuilder.CreateServer(opts =>
{
opts.ClientSecret = "123";
opts.Backchannel = new HttpClient(mockHandler.Object);
}, opts =>
{
opts.Audience = "123";
opts.UseRefreshTokens = true;
opts.AccessTokenExpirationLeeway = TimeSpan.FromSeconds(5);
}))
{
using (var client = server.CreateClient())
{
var loginResponse = (await client.SendAsync($"{TestServerBuilder.Host}/{TestServerBuilder.Login}"));
var setCookie = Assert.Single(loginResponse.Headers, h => h.Key == "Set-Cookie");

var queryParameters = UriUtils.GetQueryParams(loginResponse.Headers.Location);
nonce = queryParameters["nonce"];
var state = queryParameters["state"];

var message = new HttpRequestMessage(HttpMethod.Get, $"{TestServerBuilder.Host}/{TestServerBuilder.Callback}?state={state}&nonce={nonce}&code=123");
var callbackResponse = (await client.SendAsync(message, setCookie.Value));

callbackResponse.Headers.Location.OriginalString.Should().Be("/");

var response = await client.SendAsync($"{TestServerBuilder.Host}/{TestServerBuilder.Process}", callbackResponse.Headers.GetValues("Set-Cookie"));
var content = JObject.Parse(response.Content.ReadAsStringAsync().Result);

mockHandler.Verify();

content.GetValue("RefreshToken").Value<string>().Should().Be("456");
}
}
}

[Fact]
public async void Should_Call_On_Access_Token_Missing()
{
Expand Down
Loading