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 ;
52using Microsoft . Extensions . Options ;
3+ using OneOf ;
64using OpenShock . Common . Utils ;
5+ using System . Net . Http . Headers ;
6+ using System . Text . Json ;
77
88namespace 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+ }
0 commit comments