Skip to content

Commit d7cfd95

Browse files
authored
Add custom authentication handler for Strava tokens (#44)
Making it possible to authenticate with the API using a Strava token, either passed by header `X-Strava-Token` or query parameter `token`. The token will be used in an athlete lookup towards the Strava API and the user is authenticated if that Strava athlete exists in Tevling's DB. To protect an endpoint/controller and require a Strava token, simply add this attribute: `[Authorize(AuthenticationSchemes = StravaAuthenticationDefaults.AuthenticationScheme)]`
1 parent 62d4bf0 commit d7cfd95

File tree

10 files changed

+137
-1
lines changed

10 files changed

+137
-1
lines changed
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
using Microsoft.AspNetCore.Authentication;
2+
3+
namespace Tevling.Authentication;
4+
5+
public static class AuthenticationBuilderExtensions
6+
{
7+
public static AuthenticationBuilder AddStravaAuthentication(
8+
this AuthenticationBuilder builder,
9+
Action<StravaAuthenticationOptions>? configureOptions = null)
10+
{
11+
builder.AddScheme<StravaAuthenticationOptions, StravaAuthenticationHandler>(
12+
StravaAuthenticationDefaults.AuthenticationScheme,
13+
null,
14+
configureOptions);
15+
16+
return builder;
17+
}
18+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
namespace Tevling.Authentication;
2+
3+
public static class StravaAuthenticationDefaults
4+
{
5+
public const string AuthenticationScheme = "Strava";
6+
}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
using System.Security.Claims;
2+
using System.Text.Encodings.Web;
3+
using Microsoft.AspNetCore.Authentication;
4+
using Microsoft.Extensions.Options;
5+
using Microsoft.Extensions.Primitives;
6+
using Tevling.Strava;
7+
8+
namespace Tevling.Authentication;
9+
10+
public class StravaAuthenticationHandler(
11+
IStravaClient stravaClient,
12+
IAthleteService athleteService,
13+
IOptionsMonitor<StravaAuthenticationOptions> options,
14+
ILoggerFactory logger,
15+
UrlEncoder encoder)
16+
: AuthenticationHandler<StravaAuthenticationOptions>(options, logger, encoder)
17+
{
18+
protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
19+
{
20+
if (!Request.Headers.TryGetValue(Options.TokenHeaderName, out StringValues tokenValues) &&
21+
!Request.Query.TryGetValue(Options.TokenQueryName, out tokenValues))
22+
{
23+
return AuthenticateResult.NoResult();
24+
}
25+
26+
string? token = tokenValues.Single();
27+
28+
if (token is null)
29+
{
30+
return AuthenticateResult.Fail("Token is null");
31+
}
32+
33+
SummaryAthlete stravaAthlete = await stravaClient.GetAuthenticatedAthleteAsync(token);
34+
Athlete? athlete = await athleteService.GetAthleteByStravaIdAsync(stravaAthlete.Id);
35+
36+
if (athlete is null)
37+
{
38+
return AuthenticateResult.Fail("Unknown athlete");
39+
}
40+
41+
Claim[] claims =
42+
[
43+
new(ClaimTypes.Name, athlete.Name),
44+
new(ClaimTypes.NameIdentifier, athlete.Id.ToString()),
45+
];
46+
47+
ClaimsIdentity claimsIdentity = new(claims, Scheme.Name);
48+
ClaimsPrincipal claimsPrincipal = new(claimsIdentity);
49+
AuthenticationTicket authenticationTicket = new(claimsPrincipal, Scheme.Name);
50+
51+
return AuthenticateResult.Success(authenticationTicket);
52+
}
53+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
using Microsoft.AspNetCore.Authentication;
2+
3+
namespace Tevling.Authentication;
4+
5+
public class StravaAuthenticationOptions : AuthenticationSchemeOptions
6+
{
7+
public string TokenHeaderName { get; set; } = "X-Strava-Token";
8+
public string TokenQueryName { get; set; } = "token";
9+
}

Tevling/Clients/IStravaClient.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,5 +25,9 @@ Task<SummaryActivity[]> GetAthleteActivitiesAsync(
2525
int? pageSize = null,
2626
CancellationToken ct = default);
2727

28+
Task<SummaryAthlete> GetAuthenticatedAthleteAsync(
29+
string accessToken,
30+
CancellationToken ct = default);
31+
2832
Task DeauthorizeAppAsync(string accessToken, CancellationToken ct = default);
2933
}

Tevling/Clients/StravaClient.cs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,24 @@ public async Task<SummaryActivity[]> GetAthleteActivitiesAsync(
151151
}
152152
}
153153

154+
public async Task<SummaryAthlete> GetAuthenticatedAthleteAsync(
155+
string accessToken,
156+
CancellationToken ct = default)
157+
{
158+
HttpRequestMessage request = new(HttpMethod.Get, "athlete");
159+
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
160+
161+
HttpResponseMessage response = await _httpClient.SendAsync(request, ct);
162+
163+
await ThrowIfUnsuccessful(response, ct);
164+
165+
string responseBody = await response.Content.ReadAsStringAsync(ct);
166+
SummaryAthlete athlete = JsonSerializer.Deserialize<SummaryAthlete>(responseBody) ??
167+
throw new Exception("Error deserializing athlete");
168+
169+
return athlete;
170+
}
171+
154172
public async Task DeauthorizeAppAsync(string accessToken, CancellationToken ct = default)
155173
{
156174
HttpRequestMessage request = new(HttpMethod.Post, _stravaConfig.DeauthorizeUri);

Tevling/Controllers/DevController.cs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,22 @@ public IActionResult GetAthleteActivities(
8080
return new JsonResult(activities);
8181
}
8282

83+
[HttpGet]
84+
[Route("strava/athlete")]
85+
public IActionResult GetAuthenticatedAthlete()
86+
{
87+
SummaryAthlete athlete = new()
88+
{
89+
Id = 1337,
90+
Firstname = "Trainer",
91+
Lastname = "McTrainface",
92+
Profile =
93+
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAOEAAADhCAMAAAAJbSJIAAAAY1BMVEUDAwP///8AAACOjo7v7+/p6elkZGSdnZ36+vr19fXc3NypqanS0tI0NDSAgICIiIhzc3PExMQmJiYSEhKysrJra2sMDAxLS0uYmJi5ubmkpKRISEhvb28dHR1bW1s5OTnW1tYYldiwAAACt0lEQVR4nO3ca3OiMBSAYXNE8YqX2mrXtu7//5WLdt1uh8CMITkndN7nM5PhLVgYAhmNAAAAAAAAAAAAAAAAAAAAAAAAgDASmXXPd9c92k+rcSyb7TmryHpf3nYzF9mifM+lUeQUPe/Tbp1Do8jHPE3f1cE+UWSZrq9W7I0TRVZJA2tn00SFQOculomJT9FPM8PfonwoBDq3MisUSfhf9H9bq0Q56QS6udF5KpLoQt9UGRW+aQW6hVHhTq3QPZkk6p2kRqep7PUC6/tTi8KpYqHJJVEqxcKJSeFYsbCgULOwKJ+nwaqWK1BOhWXPR2wX771uRoVl31tIecm7sOh/jyybrAtP/fdEJOvC5wh7IkXOhdMYhRMKlVAYPjCFWigMH5hCLRSGD0yhFgq/bfvIGyXDK3z0rZnBFXbN9C/Wvr/I4Ao754k9R3F4hV2BbjP8Qu9DiS8lhRQmROF9MwqbA1OohcL7ZhQ2B6ZQC4X3zShsDkyhFgrvm1HYHJhCLRTeN6OwOTCFWij8t92iq/D4EwrXHYG/fsK8RX2ebsoWniM4xMKu6TXvwMMrfHRgCrVQGD4whVooDB+YQi0Uhg9MoRYKwwfOujDGN9d5f/e0678ncsy6MMJiMvI778L5S78vEFve1cyo0Llx6PexN0ffEcyssN6dSbDWIfMqTMCm0Pe9bio2aypsFQtNFqiRs2JhhOtsQGH3hERcEb4RD0nsnJCIy/twPH1hqRZotCidvKsVLk0CR4pLYVktgNk5bxbTq1FgnXjQKbRbGlJ8S1jEF2OxjeBEjRXNfPPEionpb2zsfoR/Ey+JV96zPYK3xLQL7Vr+Br8St8kWan21X+z6RqRKcYs6W2azKPv1ZYun6rCaFLFMFrvTse1bbyO9Hj+1sG4CAAAAAAAAAAAAAAAAAAAAAADAMP0Bvys1OxI64E0AAAAASUVORK5CYII=",
94+
};
95+
96+
return new JsonResult(athlete);
97+
}
98+
8399
[HttpPost]
84100
[Route("strava/token")]
85101
public IActionResult StravaTokenEndpoint()

Tevling/Program.cs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
using Microsoft.FeatureManagement;
77
using Serilog;
88
using Serilog.Events;
9+
using Tevling.Authentication;
910

1011
Log.Logger = new LoggerConfiguration()
1112
.MinimumLevel.Override("Microsoft", LogEventLevel.Information)
@@ -50,7 +51,8 @@
5051
{
5152
options.LoginPath = "/login";
5253
options.ReturnUrlParameter = "returnUrl";
53-
});
54+
})
55+
.AddStravaAuthentication();
5456

5557
// Make sure our data directoy used to store the SQLite DB file exists.
5658
string dataDir = Path.Join(Environment.CurrentDirectory, "storage");

Tevling/Services/AthleteService.cs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,15 @@ public class AthleteService(
2828
return athlete;
2929
}
3030

31+
public async Task<Athlete?> GetAthleteByStravaIdAsync(long stravaId, CancellationToken ct = default)
32+
{
33+
await using DataContext dataContext = await dataContextFactory.CreateDbContextAsync(ct);
34+
35+
Athlete? athlete = await dataContext.Athletes.FirstOrDefaultAsync(a => a.StravaId == stravaId, ct);
36+
37+
return athlete;
38+
}
39+
3140
public async Task<Athlete[]> GetAthletesAsync(
3241
AthleteFilter? filter = null,
3342
Paging? paging = null,

Tevling/Services/IAthleteService.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ namespace Tevling.Services;
33
public interface IAthleteService
44
{
55
Task<Athlete?> GetAthleteByIdAsync(int athleteId, CancellationToken ct = default);
6+
Task<Athlete?> GetAthleteByStravaIdAsync(long stravaId, CancellationToken ct = default);
67

78
Task<Athlete[]> GetAthletesAsync(
89
AthleteFilter? filter = null,

0 commit comments

Comments
 (0)