Skip to content

Commit 98ea99e

Browse files
committed
More improvements
1 parent 0df7580 commit 98ea99e

9 files changed

Lines changed: 275 additions & 92 deletions

File tree

API/Controller/Account/Authenticated/OAuthConnectionAdd.cs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,13 @@ public async Task<IActionResult> AddOAuthConnection([FromRoute] string provider,
1717
return Problem(OAuthError.AlreadyExists);
1818
}
1919

20-
var result = handler.BuildAuthorizeUrl(HttpContext, new OAuthStartContext(string.IsNullOrWhiteSpace(returnTo) ? null : returnTo));
20+
// Private authorize endpoint => Link flow
21+
var ctx = new OAuthStartContext(
22+
ReturnTo: string.IsNullOrWhiteSpace(returnTo) ? null : returnTo,
23+
Flow: OAuthFlow.Link
24+
);
25+
26+
var result = await handler.BuildAuthorizeUrlAsync(HttpContext, ctx);
2127
return result.Match<IActionResult>(
2228
Redirect,
2329
error => Problem(title: error.Code, detail: error.Description)

API/Controller/OAuth/Authorize.cs

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,19 +2,26 @@
22
using Microsoft.AspNetCore.RateLimiting;
33
using OpenShock.API.Services.OAuth;
44
using OpenShock.Common.Errors;
5+
using System.Threading.Tasks;
56

67
namespace OpenShock.API.Controller.OAuth;
78

89
public sealed partial class OAuthController
910
{
1011
[EnableRateLimiting("auth")]
1112
[HttpPost("{provider}/authorize")]
12-
public IActionResult OAuthAuthorize([FromRoute] string provider, [FromQuery(Name = "return_to")] string? returnTo)
13+
public async Task<IActionResult> OAuthAuthorize([FromRoute] string provider, [FromQuery(Name = "return_to")] string? returnTo)
1314
{
1415
if (!_registry.TryGet(provider, out var handler))
1516
return Problem(OAuthError.ProviderNotSupported);
1617

17-
var result = handler.BuildAuthorizeUrl(HttpContext, new OAuthStartContext(string.IsNullOrWhiteSpace(returnTo) ? null : returnTo));
18+
// Public authorize endpoint => SignIn flow
19+
var ctx = new OAuthStartContext(
20+
ReturnTo: string.IsNullOrWhiteSpace(returnTo) ? null : returnTo,
21+
Flow: OAuthFlow.SignIn
22+
);
23+
24+
var result = await handler.BuildAuthorizeUrlAsync(HttpContext, ctx);
1825
return result.Match<IActionResult>(
1926
Redirect,
2027
error => Problem(title: error.Code, detail: error.Description)

API/Services/OAuth/CookieOAuthStore.cs

Lines changed: 0 additions & 30 deletions
This file was deleted.
Lines changed: 133 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
1-
using OneOf;
2-
using System.Net.Http.Headers;
3-
using System.Text.Json;
4-
using Microsoft.AspNetCore.Http.Extensions;
1+
using Microsoft.AspNetCore.Http.Extensions;
52
using Microsoft.Extensions.Options;
3+
using OneOf;
64
using OpenShock.Common.Utils;
5+
using System.Net.Http.Headers;
6+
using System.Text.Json;
77

88
namespace OpenShock.API.Services.OAuth.Discord;
99

@@ -12,88 +12,172 @@ public sealed class DiscordOAuthHandler : IOAuthHandler
1212
private const string AuthorizeEndpoint = "https://discord.com/oauth2/authorize";
1313
private const string TokenEndpoint = "https://discord.com/api/oauth2/token";
1414
private const string UserInfoEndpoint = "https://discord.com/api/users/@me";
15-
16-
private const string CallbackPath ="/1/account/oauth/callback/discord";
17-
15+
16+
private const string CallbackPath = "/1/account/oauth/callback/discord";
17+
1818
private readonly IHttpClientFactory _http;
19-
private readonly IOptions<DiscordOAuthOptions> _opt;
20-
private readonly IOAuthStateStore _state;
19+
private readonly DiscordOAuthOptions _opt;
20+
private readonly IOAuthStateStore _stateStore;
2121

22-
public DiscordOAuthHandler(IHttpClientFactory http, IOptions<DiscordOAuthOptions> opt, IOAuthStateStore state)
22+
public DiscordOAuthHandler(
23+
IHttpClientFactory http,
24+
IOptions<DiscordOAuthOptions> opt,
25+
IOAuthStateStore stateStore)
2326
{
2427
_http = http;
25-
_opt = opt;
26-
_state = state;
28+
_opt = opt.Value;
29+
_stateStore = stateStore;
2730
}
2831

2932
public string Key => "discord";
3033

31-
public OneOf<string, OAuthErrorResult> BuildAuthorizeUrl(HttpContext http, OAuthStartContext ctx)
34+
public async Task<OneOf<string, OAuthErrorResult>> BuildAuthorizeUrlAsync(HttpContext http, OAuthStartContext ctx)
3235
{
33-
var o = _opt.Value;
34-
var callback = new Uri(new Uri("https://api.openhshock.dev"), CallbackPath).ToString(); // TODO: Make the base URL dynamic somehow
36+
if (string.IsNullOrWhiteSpace(_opt.ClientId))
37+
return new OAuthErrorResult("config_error", "Discord OAuth is not configured.");
38+
39+
var callback = BuildCallbackUrl();
40+
if (callback is null)
41+
return new OAuthErrorResult("config_error", "Callback base URL is not configured.");
42+
43+
// Opaque nonce for state
44+
var nonce = CryptoUtils.RandomString(64);
45+
46+
// Save full envelope in Redis with TTL
47+
var env = new OAuthStateEnvelope(
48+
Provider: Key,
49+
State: nonce,
50+
Flow: ctx.Flow,
51+
ReturnTo: ctx.ReturnTo,
52+
UserId: null, // set if you add an authenticated “link” endpoint
53+
CodeVerifier: null, // add PKCE later if desired
54+
CreatedAt: DateTimeOffset.UtcNow
55+
);
3556

36-
var state = CryptoUtils.RandomString(64);
37-
_state.Save(http, Key, state, ctx.ReturnTo);
57+
// 10 minutes is plenty
58+
await _stateStore.SaveAsync(http, env, TimeSpan.FromMinutes(10));
3859

60+
// Build Discord authorize URL
3961
var qb = new QueryBuilder
4062
{
4163
{ "response_type", "code" },
42-
{ "client_id", o.ClientId },
64+
{ "client_id", _opt.ClientId },
4365
{ "scope", "identify email" },
4466
{ "redirect_uri", callback },
45-
{ "state", state }
67+
{ "state", nonce }
4668
};
47-
return new UriBuilder(AuthorizeEndpoint) { Query = qb.ToString() }.Uri.ToString();
69+
70+
var url = new UriBuilder(AuthorizeEndpoint) { Query = qb.ToString() }.Uri.ToString();
71+
return url;
4872
}
4973

5074
public async Task<OneOf<OAuthCallbackResult, OAuthErrorResult>> HandleCallbackAsync(HttpContext http, IQueryCollection query)
5175
{
52-
var o = _opt.Value;
76+
if (string.IsNullOrWhiteSpace(_opt.ClientId) || string.IsNullOrWhiteSpace(_opt.ClientSecret))
77+
return new OAuthErrorResult("config_error", "Discord OAuth is not configured.");
5378

54-
var code = query["code"].ToString();
79+
var code = query["code"].ToString();
5580
var state = query["state"].ToString();
81+
5682
if (string.IsNullOrEmpty(code) || string.IsNullOrEmpty(state))
57-
throw new InvalidOperationException("Missing code/state");
83+
return new OAuthErrorResult("invalid_request", "Missing 'code' or 'state'.");
5884

59-
var saved = _state.ReadAndClear(http, Key);
60-
if (saved is null || !string.Equals(saved.Value.State, state, StringComparison.Ordinal))
61-
throw new InvalidOperationException("Invalid state");
85+
var env = await _stateStore.ReadAndClearAsync(http, Key, state);
86+
if (env is null)
87+
return new OAuthErrorResult("state_invalid", "Invalid or expired state.");
6288

63-
var callback = new Uri(new Uri("https://api.openhshock.dev"), CallbackPath).ToString(); // TODO: Make the base URL dynamic somehow
89+
var callback = BuildCallbackUrl();
90+
if (callback is null)
91+
return new OAuthErrorResult("config_error", "Callback base URL is not configured.");
6492

93+
var ct = http.RequestAborted;
6594
var client = _http.CreateClient();
66-
using var tokenReq = new HttpRequestMessage(HttpMethod.Post, TokenEndpoint)
95+
96+
// Exchange code for token
97+
var accessResult = await ExchangeCodeForAccessTokenAsync(client, code, callback, ct);
98+
if (accessResult.TryPickT1(out var tokenErr, out var accessToken))
99+
return tokenErr;
100+
101+
// Fetch user info
102+
var userResult = await FetchDiscordUserAsync(client, accessToken, ct);
103+
if (userResult.TryPickT1(out var userErr, out var me))
104+
return userErr;
105+
106+
var externalId = me.GetProperty("id").GetString()!;
107+
var username = me.GetProperty("username").GetString();
108+
string? email = me.TryGetProperty("email", out var emailEl) ? emailEl.GetString() : null;
109+
110+
var user = new ExternalUser(
111+
Provider: Key,
112+
ExternalId: externalId,
113+
Username: username,
114+
Email: email,
115+
AvatarUrl: null
116+
);
117+
118+
http.Items["oauth_flow"] = env.Flow;
119+
120+
return new OAuthCallbackResult(user);
121+
}
122+
123+
// ------------------
124+
// Helper methods
125+
// ------------------
126+
127+
private string? BuildCallbackUrl()
128+
{
129+
try
130+
{
131+
return new Uri(new Uri("https://api.openhshock.dev"), CallbackPath).ToString();
132+
}
133+
catch
134+
{
135+
return null;
136+
}
137+
}
138+
139+
private async Task<OneOf<string, OAuthErrorResult>> ExchangeCodeForAccessTokenAsync(
140+
HttpClient client,
141+
string code,
142+
string callback,
143+
CancellationToken ct)
144+
{
145+
using var request = new HttpRequestMessage(HttpMethod.Post, TokenEndpoint)
67146
{
68-
Content = new FormUrlEncodedContent(new Dictionary<string,string>
147+
Content = new FormUrlEncodedContent(new Dictionary<string, string>
69148
{
70-
["client_id"] = o.ClientId,
71-
["client_secret"] = o.ClientSecret,
149+
["client_id"] = _opt.ClientId,
150+
["client_secret"] = _opt.ClientSecret,
72151
["grant_type"] = "authorization_code",
73152
["code"] = code,
74153
["redirect_uri"] = callback
75154
})
76155
};
77-
using var tokenRes = await client.SendAsync(tokenReq);
78-
tokenRes.EnsureSuccessStatusCode();
156+
using var response = await client.SendAsync(request, ct);
157+
if (!response.IsSuccessStatusCode)
158+
return new OAuthErrorResult("token_exchange_failed", $"Token exchange failed ({(int)response.StatusCode}).");
79159

80-
var token = JsonSerializer.Deserialize<JsonElement>(await tokenRes.Content.ReadAsStringAsync());
81-
var access = token.GetProperty("access_token").GetString()!;
160+
var tokenEl = await response.Content.ReadFromJsonAsync<JsonElement>(ct);
82161

83-
using var meReq = new HttpRequestMessage(HttpMethod.Get, UserInfoEndpoint);
84-
meReq.Headers.Authorization = new AuthenticationHeaderValue("Bearer", access);
85-
using var meRes = await client.SendAsync(meReq);
86-
meRes.EnsureSuccessStatusCode();
162+
if (!tokenEl.TryGetProperty("access_token", out var accessEl) ||
163+
string.IsNullOrWhiteSpace(accessEl.GetString()))
164+
return new OAuthErrorResult("token_exchange_failed", "No access token from provider.");
87165

88-
var me = JsonSerializer.Deserialize<JsonElement>(await meRes.Content.ReadAsStringAsync());
89-
var user = new ExternalUser(
90-
Provider: Key,
91-
ExternalId: me.GetProperty("id").GetString()!,
92-
Username: me.GetProperty("username").GetString(),
93-
Email: me.GetProperty("email").GetString(),
94-
AvatarUrl: null // build if you need it
95-
);
166+
return accessEl.GetString()!;
167+
}
96168

97-
return new OAuthCallbackResult(user);
169+
private async Task<OneOf<JsonElement, OAuthErrorResult>> FetchDiscordUserAsync(
170+
HttpClient client,
171+
string accessToken,
172+
CancellationToken ct)
173+
{
174+
using var request = new HttpRequestMessage(HttpMethod.Get, UserInfoEndpoint);
175+
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
176+
177+
using var response = await client.SendAsync(request, ct);
178+
if (!response.IsSuccessStatusCode)
179+
return new OAuthErrorResult("profile_fetch_failed", $"Failed to fetch user profile ({(int)response.StatusCode}).");
180+
181+
return await response.Content.ReadFromJsonAsync<JsonElement>(ct);
98182
}
99-
}
183+
}

API/Services/OAuth/IOAuthHandler.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ public sealed record ExternalUser(
99
string? Email, // provider email
1010
string? AvatarUrl);
1111

12-
public sealed record OAuthStartContext(string? ReturnTo);
12+
public sealed record OAuthStartContext(string? ReturnTo, OAuthFlow Flow);
1313
public sealed record OAuthCallbackResult(ExternalUser User);
1414

1515
public sealed record OAuthErrorResult(string Code, string Description);
@@ -20,7 +20,7 @@ public interface IOAuthHandler
2020
string Key { get; }
2121

2222
/// Build the provider authorize URL and set any cookies you need (state, pkce, return_to).
23-
OneOf<string, OAuthErrorResult> BuildAuthorizeUrl(HttpContext http, OAuthStartContext ctx);
23+
Task<OneOf<string, OAuthErrorResult>> BuildAuthorizeUrlAsync(HttpContext http, OAuthStartContext ctx);
2424

2525
/// Handle callback: validate state, exchange code, fetch user, clear cookies, etc.
2626
Task<OneOf<OAuthCallbackResult, OAuthErrorResult>> HandleCallbackAsync(HttpContext http, IQueryCollection query);
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
namespace OpenShock.API.Services.OAuth;
2+
3+
public interface IOAuthStateStore
4+
{
5+
Task SaveAsync(HttpContext http, OAuthStateEnvelope envelope, TimeSpan ttl);
6+
Task<OAuthStateEnvelope?> ReadAndClearAsync(HttpContext http, string provider, string state);
7+
}
8+
9+
public enum OAuthFlow
10+
{
11+
SignIn,
12+
Link
13+
}
14+
public sealed record OAuthStateEnvelope(
15+
string Provider,
16+
string State, // opaque nonce
17+
OAuthFlow Flow, // SignIn | Link
18+
string? ReturnTo, // optional allow-listed redirect
19+
Guid? UserId, // set for Link flow
20+
string? CodeVerifier, // if using PKCE
21+
DateTimeOffset CreatedAt
22+
);

API/Services/OAuth/IOAuthStore.cs

Lines changed: 0 additions & 7 deletions
This file was deleted.

API/Services/OAuth/OAuthServiceCollectionExtensions.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ public static class OAuthServiceCollectionExtensions
88
public static IOAuthBuilder AddOAuth(this IServiceCollection services)
99
{
1010
// Default state store if none registered
11-
services.TryAddSingleton<IOAuthStateStore, CookieOAuthStateStore>();
11+
services.TryAddSingleton<IOAuthStateStore, RedisOAuthStateStore>();
1212

1313
// Registry built from IEnumerable<IOAuthHandler>
1414
services.TryAddSingleton<IOAuthHandlerRegistry, OAuthHandlerRegistry>();

0 commit comments

Comments
 (0)