Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
8e2568d
feat: add discord oauth login
LucHeart Sep 2, 2025
ee05a11
Copy over some OAuth logic from ZapMe
hhvrc Sep 2, 2025
259b0e1
Update OAuthAuthenticate.cs
hhvrc Sep 2, 2025
7fcdfae
More cleanup
hhvrc Sep 2, 2025
ee06381
Move some stuff around
hhvrc Sep 2, 2025
53cfa0e
Merge branch 'develop' into codex/add-oauth-signup-and-login-via-discord
hhvrc Sep 2, 2025
4da7889
Update IAuthenticationSchemeProviderExtensions.cs
hhvrc Sep 2, 2025
2770175
Some more cleanup
hhvrc Sep 2, 2025
d4860dd
Remove Microsoft.Authentication base OAuth handler
hhvrc Sep 2, 2025
70eff56
Update API.csproj
hhvrc Sep 2, 2025
6f2f4d0
Clean up imports
hhvrc Sep 2, 2025
352ac96
More cleanup
hhvrc Sep 2, 2025
57378bd
More cleanup
hhvrc Sep 2, 2025
acc56e0
VERY basic implementation
hhvrc Sep 2, 2025
9761179
Broader implementation
hhvrc Sep 2, 2025
59d0be0
More reverts
hhvrc Sep 2, 2025
27bf171
Fix up some more stuff
hhvrc Sep 2, 2025
54744e2
Improve implementation
hhvrc Sep 2, 2025
bd46c45
Attempt to fix integration test failure
hhvrc Sep 2, 2025
20d0baa
Fail password logins for OAuth accounts
hhvrc Sep 2, 2025
3d96883
Add endpoint to list OAuth connections for current account
hhvrc Sep 2, 2025
debcb18
Add connection delete endpoint
hhvrc Sep 2, 2025
7d8e2f3
Create AddConnection endpoint
hhvrc Sep 2, 2025
19c2e9d
Oops
hhvrc Sep 2, 2025
094d43a
Add TryAdd
hhvrc Sep 2, 2025
eb53769
Fix FK issue and rename DB Model
hhvrc Sep 2, 2025
0df7580
Move controllers around a bit
hhvrc Sep 3, 2025
98ea99e
More improvements
hhvrc Sep 3, 2025
065b6a4
Clean up more stuff
hhvrc Sep 3, 2025
a89bff0
Reduce filecount
hhvrc Sep 3, 2025
0059a65
Let's not reinvent the wheel...
hhvrc Sep 3, 2025
254bbf1
Clean up Complete endpoint logic a bit
hhvrc Sep 3, 2025
53884b3
Better?
hhvrc Sep 3, 2025
8bd6816
Yeah.......
hhvrc Sep 3, 2025
9cf7243
inbetween swapping back again...
hhvrc Sep 3, 2025
0d8928a
Idk where this is going
hhvrc Sep 3, 2025
a37b988
Absolute cinema.
hhvrc Sep 4, 2025
3dad263
What now?
hhvrc Sep 4, 2025
21db899
Use proper frontend endpoints
hhvrc Sep 4, 2025
2ca0b38
More cleanup
hhvrc Sep 4, 2025
0e2ba32
Code quality improvements
hhvrc Sep 4, 2025
0fd6e3d
More docs and clean up stuff
hhvrc Sep 4, 2025
7e9a5fa
Revert other changes done by Codex
hhvrc Sep 4, 2025
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
1 change: 1 addition & 0 deletions API/API.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
<Import Project="../Shared.props" />

<ItemGroup>
<PackageReference Include="AspNet.Security.OAuth.Discord" Version="9.4.0" />
<PackageReference Include="Fluid.Core" Version="2.25.0" />
<PackageReference Include="MailKit" Version="4.13.0" />
</ItemGroup>
Expand Down
2 changes: 1 addition & 1 deletion API/Controller/Account/Authenticated/ChangePassword.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ public sealed partial class AuthenticatedAccountController
[ProducesResponseType(StatusCodes.Status200OK)]
public async Task<IActionResult> ChangePassword(ChangePasswordRequest data)
{
if (!HashingUtils.VerifyPassword(data.OldPassword, CurrentUser.PasswordHash).Verified)
if (!string.IsNullOrEmpty(CurrentUser.PasswordHash) && !HashingUtils.VerifyPassword(data.OldPassword, CurrentUser.PasswordHash).Verified)
{
return Problem(AccountError.PasswordChangeInvalidPassword);
}
Expand Down
43 changes: 43 additions & 0 deletions API/Controller/Account/Authenticated/OAuthConnectionAdd.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Mvc;
using OpenShock.API.Extensions;
using OpenShock.Common.Constants;
using OpenShock.Common.Errors;
using OpenShock.Common.Problems;
using System.Net.Mime;

namespace OpenShock.API.Controller.Account.Authenticated;

public sealed partial class AuthenticatedAccountController
{
/// <summary>
/// Start linking an OAuth provider to the current account.
/// </summary>
/// <remarks>
/// Initiates the OAuth flow (link mode) for a given provider.
/// On success this returns a <c>302 Found</c> to the provider's authorization page.
/// After consent, the OAuth middleware will call the internal callback and finally
/// redirect to <c>/1/oauth/{provider}/handoff</c>.
/// </remarks>
/// <param name="provider">Provider key (e.g. <c>discord</c>).</param>
/// <param name="schemeProvider"></param>
/// <response code="302">Redirect to the provider authorization page.</response>
/// <response code="400">Unsupported or misconfigured provider.</response>
[HttpGet("connections/{provider}/link")]
[ProducesResponseType(StatusCodes.Status302Found)]
[ProducesResponseType<OpenShockProblem>(StatusCodes.Status400BadRequest, MediaTypeNames.Application.Json)]
public async Task<IActionResult> AddOAuthConnection([FromRoute] string provider, [FromServices] IAuthenticationSchemeProvider schemeProvider)
{
if (!await schemeProvider.IsSupportedOAuthScheme(provider))
return Problem(OAuthError.UnsupportedProvider);

// Kick off provider challenge in "link" mode.
// Redirect URI is our handoff endpoint which decides next UI step.
var props = new AuthenticationProperties {
RedirectUri = $"/1/oauth/{provider}/handoff",
Items = { { "flow", AuthConstants.OAuthLinkFlow } }
};

return Challenge(props, provider);
}
}
27 changes: 27 additions & 0 deletions API/Controller/Account/Authenticated/OAuthConnectionRemove.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
ο»Ώusing Microsoft.AspNetCore.Mvc;
using OpenShock.API.Services.OAuthConnection;

namespace OpenShock.API.Controller.Account.Authenticated;

public sealed partial class AuthenticatedAccountController
{
/// <summary>
/// Remove an existing OAuth connection for the current user.
/// </summary>
/// <param name="provider">Provider key (e.g. <c>discord</c>).</param>
/// <param name="connectionService"></param>
/// <response code="204">Connection removed.</response>
/// <response code="404">No connection found for this provider.</response>
[HttpDelete("connections/{provider}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> RemoveOAuthConnection([FromRoute] string provider, [FromServices] IOAuthConnectionService connectionService)
{
var deleted = await connectionService.TryRemoveConnectionAsync(CurrentUser.Id, provider);

if (!deleted)
return NotFound();

return NoContent();
}
}
30 changes: 30 additions & 0 deletions API/Controller/Account/Authenticated/OAuthConnectionsList.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
ο»Ώusing Microsoft.AspNetCore.Mvc;
using OpenShock.API.Models.Response;
using OpenShock.API.Services.OAuthConnection;

namespace OpenShock.API.Controller.Account.Authenticated;

public sealed partial class AuthenticatedAccountController
{
/// <summary>
/// List OAuth connections linked to the current user.
/// </summary>
/// <returns>Array of connections with provider key, external id, display name and link time.</returns>
/// <response code="200">Returns the list of connections.</response>
[HttpGet("connections")]
[ProducesResponseType(StatusCodes.Status200OK)]
public async Task<OAuthConnectionResponse[]> ListOAuthConnections([FromServices] IOAuthConnectionService connectionService)
{
var connections = await connectionService.GetConnectionsAsync(CurrentUser.Id);

return connections
.Select(c => new OAuthConnectionResponse
{
ProviderKey = c.ProviderKey,
ExternalId = c.ExternalId,
DisplayName = c.DisplayName,
LinkedAt = c.CreatedAt
})
.ToArray();
}
}
3 changes: 2 additions & 1 deletion API/Controller/Account/Login.cs
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,9 @@ public async Task<IActionResult> Login(
HttpContext.SetSessionKeyCookie(ok.Token, "." + cookieDomainToUse);
return LegacyEmptyOk("Successfully logged in");
},
notActivated => Problem(AccountError.AccountNotActivated),
deactivated => Problem(AccountError.AccountDeactivated),
oauthOnly => Problem(AccountError.AccountOAuthOnly),
notActivated => Problem(AccountError.AccountNotActivated),
notFound => Problem(LoginError.InvalidCredentials)
);
}
Expand Down
3 changes: 2 additions & 1 deletion API/Controller/Account/LoginV2.cs
Original file line number Diff line number Diff line change
Expand Up @@ -62,8 +62,9 @@ public async Task<IActionResult> LoginV2(
HttpContext.SetSessionKeyCookie(ok.Token, "." + cookieDomainToUse);
return Ok(LoginV2OkResponse.FromUser(ok.User));
},
notActivated => Problem(AccountError.AccountNotActivated),
deactivated => Problem(AccountError.AccountDeactivated),
oauthOnly => Problem(AccountError.AccountOAuthOnly),
notActivated => Problem(AccountError.AccountNotActivated),
notFound => Problem(LoginError.InvalidCredentials)
);
}
Expand Down
2 changes: 1 addition & 1 deletion API/Controller/Account/Signup.cs
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ public async Task<IActionResult> SignUp([FromBody] SignUp body)
var creationAction = await _accountService.CreateAccountWithoutActivationFlowLegacyAsync(body.Email, body.Username, body.Password);
return creationAction.Match<IActionResult>(
ok => LegacyEmptyOk("Successfully signed up"),
alreadyExists => Problem(SignupError.EmailAlreadyExists)
alreadyExists => Problem(SignupError.UsernameOrEmailExists)
);
}
}
2 changes: 1 addition & 1 deletion API/Controller/Account/SignupV2.cs
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ public async Task<IActionResult> SignUpV2(
var creationAction = await _accountService.CreateAccountWithActivationFlowAsync(body.Email, body.Username, body.Password);
return creationAction.Match<IActionResult>(
_ => Ok(),
_ => Problem(SignupError.EmailAlreadyExists)
_ => Problem(SignupError.UsernameOrEmailExists)
);
}
}
42 changes: 42 additions & 0 deletions API/Controller/OAuth/Authorize.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using OpenShock.API.Extensions;
using OpenShock.Common.Constants;
using OpenShock.Common.Errors;
using OpenShock.Common.Problems;
using System.Net.Mime;

namespace OpenShock.API.Controller.OAuth;

public sealed partial class OAuthController
{
/// <summary>
/// Start OAuth authorization for a given provider (login-or-create flow).
/// </summary>
/// <remarks>
/// Initiates an OAuth challenge in "login-or-create" mode.
/// Returns <c>302</c> redirect to the provider authorization page.
/// </remarks>
/// <param name="provider">Provider key (e.g. <c>discord</c>).</param>
/// <response code="302">Redirect to the provider authorization page.</response>
/// <response code="400">Unsupported or misconfigured provider.</response>
[EnableRateLimiting("auth")]
[HttpGet("{provider}/authorize")]
[ProducesResponseType(StatusCodes.Status302Found)]
[ProducesResponseType<OpenShockProblem>(StatusCodes.Status400BadRequest, MediaTypeNames.Application.Json)]
public async Task<IActionResult> OAuthAuthorize([FromRoute] string provider)
{
if (!await _schemeProvider.IsSupportedOAuthScheme(provider))
return Problem(OAuthError.UnsupportedProvider);

// Kick off provider challenge in "login-or-create" mode.
var props = new AuthenticationProperties
{
RedirectUri = $"/1/oauth/{provider}/handoff",
Items = { { "flow", AuthConstants.OAuthLoginOrCreateFlow } }
};

return Challenge(props, provider);
}
}
Loading
Loading