diff --git a/API/API.csproj b/API/API.csproj
index 71e4d7ea..79724a3e 100644
--- a/API/API.csproj
+++ b/API/API.csproj
@@ -3,6 +3,7 @@
+
diff --git a/API/Controller/Account/Authenticated/ChangePassword.cs b/API/Controller/Account/Authenticated/ChangePassword.cs
index 622427f4..7c448501 100644
--- a/API/Controller/Account/Authenticated/ChangePassword.cs
+++ b/API/Controller/Account/Authenticated/ChangePassword.cs
@@ -17,7 +17,7 @@ public sealed partial class AuthenticatedAccountController
[ProducesResponseType(StatusCodes.Status200OK)]
public async Task ChangePassword([FromBody] 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);
}
diff --git a/API/Controller/Account/Authenticated/OAuthConnectionAdd.cs b/API/Controller/Account/Authenticated/OAuthConnectionAdd.cs
new file mode 100644
index 00000000..f8525d7f
--- /dev/null
+++ b/API/Controller/Account/Authenticated/OAuthConnectionAdd.cs
@@ -0,0 +1,34 @@
+using Microsoft.AspNetCore.Authentication;
+using Microsoft.AspNetCore.Mvc;
+using OpenShock.Common.Problems;
+using System.Net.Mime;
+using OpenShock.API.OAuth;
+
+namespace OpenShock.API.Controller.Account.Authenticated;
+
+public sealed partial class AuthenticatedAccountController
+{
+ ///
+ /// Start linking an OAuth provider to the current account.
+ ///
+ ///
+ /// Initiates the OAuth flow (link mode) for a given provider.
+ /// On success this returns a 302 Found to the provider's authorization page.
+ /// After consent, the OAuth middleware will call the internal callback and finally
+ /// redirect to /1/oauth/{provider}/handoff.
+ ///
+ /// Provider key (e.g. discord).
+ ///
+ /// Redirect to the provider authorization page.
+ /// Unsupported or misconfigured provider.
+ [HttpGet("connections/{provider}/link")]
+ [ProducesResponseType(StatusCodes.Status302Found)]
+ [ProducesResponseType(StatusCodes.Status400BadRequest, MediaTypeNames.Application.Json)]
+ public async Task AddOAuthConnection([FromRoute] string provider, [FromServices] IAuthenticationSchemeProvider schemeProvider)
+ {
+ if (!await schemeProvider.IsSupportedOAuthScheme(provider))
+ return Problem(OAuthError.UnsupportedProvider);
+
+ return OAuthUtil.StartOAuth(provider, OAuthFlow.Link);
+ }
+}
\ No newline at end of file
diff --git a/API/Controller/Account/Authenticated/OAuthConnectionRemove.cs b/API/Controller/Account/Authenticated/OAuthConnectionRemove.cs
new file mode 100644
index 00000000..8e4be621
--- /dev/null
+++ b/API/Controller/Account/Authenticated/OAuthConnectionRemove.cs
@@ -0,0 +1,28 @@
+using Microsoft.AspNetCore.Mvc;
+using OpenShock.API.Services.OAuthConnection;
+
+namespace OpenShock.API.Controller.Account.Authenticated;
+
+public sealed partial class AuthenticatedAccountController
+{
+ ///
+ /// Remove an existing OAuth connection for the current user.
+ ///
+ /// Provider key (e.g. discord).
+ ///
+ ///
+ /// Connection removed.
+ /// No connection found for this provider.
+ [HttpDelete("connections/{provider}")]
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ public async Task RemoveOAuthConnection([FromRoute] string provider, [FromServices] IOAuthConnectionService connectionService, CancellationToken cancellationToken)
+ {
+ var deleted = await connectionService.TryRemoveConnectionAsync(CurrentUser.Id, provider, cancellationToken);
+
+ if (!deleted)
+ return NotFound();
+
+ return NoContent();
+ }
+}
\ No newline at end of file
diff --git a/API/Controller/Account/Authenticated/OAuthConnectionsList.cs b/API/Controller/Account/Authenticated/OAuthConnectionsList.cs
new file mode 100644
index 00000000..3644fabe
--- /dev/null
+++ b/API/Controller/Account/Authenticated/OAuthConnectionsList.cs
@@ -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
+{
+ ///
+ /// List OAuth connections linked to the current user.
+ ///
+ /// Array of connections with provider key, external id, display name and link time.
+ /// Returns the list of connections.
+ [HttpGet("connections")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ public async Task ListOAuthConnections([FromServices] IOAuthConnectionService connectionService, CancellationToken cancellationToken)
+ {
+ var connections = await connectionService.GetConnectionsAsync(CurrentUser.Id, cancellationToken);
+
+ return connections
+ .Select(c => new OAuthConnectionResponse
+ {
+ ProviderKey = c.ProviderKey,
+ ExternalId = c.ExternalId,
+ DisplayName = c.DisplayName,
+ LinkedAt = c.CreatedAt
+ })
+ .ToArray();
+ }
+}
\ No newline at end of file
diff --git a/API/Controller/Account/Login.cs b/API/Controller/Account/Login.cs
index 156baee8..b02bafa6 100644
--- a/API/Controller/Account/Login.cs
+++ b/API/Controller/Account/Login.cs
@@ -10,6 +10,7 @@
using OpenShock.Common.Utils;
using System.Net.Mime;
using Microsoft.AspNetCore.RateLimiting;
+using OpenShock.Common.Services.Session;
namespace OpenShock.API.Controller.Account;
@@ -28,27 +29,23 @@ public sealed partial class AccountController
[MapToApiVersion("1")]
public async Task Login(
[FromBody] Login body,
- [FromServices] FrontendOptions options,
CancellationToken cancellationToken)
{
- var cookieDomainToUse = options.CookieDomains.FirstOrDefault(domain => Request.Headers.Host.ToString().EndsWith(domain, StringComparison.OrdinalIgnoreCase));
- if (cookieDomainToUse is null) return Problem(LoginError.InvalidDomain);
+ var cookieDomain = GetCurrentCookieDomain();
+ if (cookieDomain is null) return Problem(LoginError.InvalidDomain);
- var loginAction = await _accountService.CreateUserLoginSessionAsync(body.Email, body.Password, new LoginContext
+ var getAccountResult = await _accountService.GetAccountByCredentialsAsync(body.Email, body.Password, cancellationToken);
+ if (!getAccountResult.TryPickT0(out var account, out var errors))
{
- Ip = HttpContext.GetRemoteIP().ToString(),
- UserAgent = HttpContext.GetUserAgent(),
- }, cancellationToken);
+ return errors.Match(
+ notFound => Problem(LoginError.InvalidCredentials),
+ deactivated => Problem(AccountError.AccountDeactivated),
+ notActivated => Problem(AccountError.AccountNotActivated),
+ oauthOnly => Problem(AccountError.AccountOAuthOnly)
+ );
+ }
- return loginAction.Match(
- ok =>
- {
- HttpContext.SetSessionKeyCookie(ok.Token, cookieDomainToUse);
- return LegacyEmptyOk("Successfully logged in");
- },
- notActivated => Problem(AccountError.AccountNotActivated),
- deactivated => Problem(AccountError.AccountDeactivated),
- notFound => Problem(LoginError.InvalidCredentials)
- );
+ await CreateSession(account.Id, cookieDomain);
+ return LegacyEmptyOk("Successfully logged in");
}
}
\ No newline at end of file
diff --git a/API/Controller/Account/LoginV2.cs b/API/Controller/Account/LoginV2.cs
index f0cdb503..cc7d89a9 100644
--- a/API/Controller/Account/LoginV2.cs
+++ b/API/Controller/Account/LoginV2.cs
@@ -3,6 +3,7 @@
using System.Net;
using System.Net.Mime;
using Asp.Versioning;
+using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.RateLimiting;
using OpenShock.API.Services.Account;
using OpenShock.Common.Errors;
@@ -14,6 +15,7 @@
using OpenShock.API.Models.Response;
using OpenShock.API.Services.Turnstile;
using OpenShock.Common.Options;
+using OpenShock.Common.Services.Session;
namespace OpenShock.API.Controller.Account;
@@ -33,39 +35,35 @@ public sealed partial class AccountController
public async Task LoginV2(
[FromBody] LoginV2 body,
[FromServices] ICloudflareTurnstileService turnstileService,
- [FromServices] FrontendOptions options,
CancellationToken cancellationToken)
{
- var cookieDomainToUse = options.CookieDomains.FirstOrDefault(domain => Request.Headers.Host.ToString().EndsWith(domain, StringComparison.OrdinalIgnoreCase));
- if (cookieDomainToUse is null) return Problem(LoginError.InvalidDomain);
+ var cookieDomain = GetCurrentCookieDomain();
+ if (cookieDomain is null) return Problem(LoginError.InvalidDomain);
- var remoteIP = HttpContext.GetRemoteIP();
+ var remoteIp = HttpContext.GetRemoteIP();
- var turnStile = await turnstileService.VerifyUserResponseTokenAsync(body.TurnstileResponse, remoteIP, cancellationToken);
- if (!turnStile.IsT0)
+ var turnStile = await turnstileService.VerifyUserResponseTokenAsync(body.TurnstileResponse, remoteIp, cancellationToken);
+ if (!turnStile.TryPickT0(out _, out var cfErrors))
{
- var cfErrors = turnStile.AsT1.Value;
- if (cfErrors.All(err => err == CloudflareTurnstileError.InvalidResponse))
+ if (cfErrors.Value.All(err => err == CloudflareTurnstileError.InvalidResponse))
return Problem(TurnstileError.InvalidTurnstile);
return Problem(new OpenShockProblem("InternalServerError", "Internal Server Error", HttpStatusCode.InternalServerError));
}
-
- var loginAction = await _accountService.CreateUserLoginSessionAsync(body.UsernameOrEmail, body.Password, new LoginContext
+
+ var getAccountResult = await _accountService.GetAccountByCredentialsAsync(body.UsernameOrEmail, body.Password, cancellationToken);
+ if (!getAccountResult.TryPickT0(out var account, out var errors))
{
- Ip = remoteIP.ToString(),
- UserAgent = HttpContext.GetUserAgent(),
- }, cancellationToken);
-
- return loginAction.Match(
- ok =>
- {
- HttpContext.SetSessionKeyCookie(ok.Token, cookieDomainToUse);
- return Ok(LoginV2OkResponse.FromUser(ok.User));
- },
- notActivated => Problem(AccountError.AccountNotActivated),
- deactivated => Problem(AccountError.AccountDeactivated),
- notFound => Problem(LoginError.InvalidCredentials)
- );
+ return errors.Match(
+ notFound => Problem(LoginError.InvalidCredentials),
+ deactivated => Problem(AccountError.AccountDeactivated),
+ notActivated => Problem(AccountError.AccountNotActivated),
+ oauthOnly => Problem(AccountError.AccountOAuthOnly)
+ );
+ }
+
+ await CreateSession(account.Id, cookieDomain);
+
+ return Ok(LoginV2OkResponse.FromUser(account));
}
}
\ No newline at end of file
diff --git a/API/Controller/Account/Logout.cs b/API/Controller/Account/Logout.cs
index 89f16111..b27e0efe 100644
--- a/API/Controller/Account/Logout.cs
+++ b/API/Controller/Account/Logout.cs
@@ -12,9 +12,7 @@ public sealed partial class AccountController
[HttpPost("logout")]
[ProducesResponseType(StatusCodes.Status200OK)]
[MapToApiVersion("1")]
- public async Task Logout(
- [FromServices] ISessionService sessionService,
- [FromServices] FrontendOptions options)
+ public async Task Logout([FromServices] ISessionService sessionService)
{
// Remove session if valid
if (HttpContext.TryGetUserSessionToken(out var sessionToken))
@@ -23,18 +21,7 @@ public async Task Logout(
}
// Make sure cookie is removed, no matter if authenticated or not
- var cookieDomainToUse = options.CookieDomains.FirstOrDefault(domain => Request.Headers.Host.ToString().EndsWith(domain, StringComparison.OrdinalIgnoreCase));
- if (cookieDomainToUse is not null)
- {
- HttpContext.RemoveSessionKeyCookie(cookieDomainToUse);
- }
- else // Fallback to all domains
- {
- foreach (var domain in options.CookieDomains)
- {
- HttpContext.RemoveSessionKeyCookie(domain);
- }
- }
+ RemoveSessionKeyCookie();
// its always a success, logout endpoints should be idempotent
return Ok();
diff --git a/API/Controller/Account/Signup.cs b/API/Controller/Account/Signup.cs
index 5e04868e..94e2f12e 100644
--- a/API/Controller/Account/Signup.cs
+++ b/API/Controller/Account/Signup.cs
@@ -27,7 +27,7 @@ public async Task SignUp([FromBody] SignUp body)
var creationAction = await _accountService.CreateAccountWithoutActivationFlowLegacyAsync(body.Email, body.Username, body.Password);
return creationAction.Match(
ok => LegacyEmptyOk("Successfully signed up"),
- alreadyExists => Problem(SignupError.EmailAlreadyExists)
+ alreadyExists => Problem(SignupError.UsernameOrEmailExists)
);
}
}
\ No newline at end of file
diff --git a/API/Controller/Account/SignupV2.cs b/API/Controller/Account/SignupV2.cs
index 703f4f28..ab5d06bb 100644
--- a/API/Controller/Account/SignupV2.cs
+++ b/API/Controller/Account/SignupV2.cs
@@ -46,7 +46,7 @@ public async Task SignUpV2(
var creationAction = await _accountService.CreateAccountWithActivationFlowAsync(body.Email, body.Username, body.Password);
return creationAction.Match(
_ => Ok(),
- _ => Problem(SignupError.EmailAlreadyExists)
+ _ => Problem(SignupError.UsernameOrEmailExists)
);
}
}
\ No newline at end of file
diff --git a/API/Controller/OAuth/Authorize.cs b/API/Controller/OAuth/Authorize.cs
new file mode 100644
index 00000000..d9e1d6cf
--- /dev/null
+++ b/API/Controller/OAuth/Authorize.cs
@@ -0,0 +1,38 @@
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.RateLimiting;
+using OpenShock.Common.Problems;
+using System.Net.Mime;
+using OpenShock.API.OAuth;
+using OpenShock.Common.Utils;
+
+namespace OpenShock.API.Controller.OAuth;
+
+public sealed partial class OAuthController
+{
+ ///
+ /// Start OAuth authorization for a given provider (login-or-create flow).
+ ///
+ ///
+ /// Initiates an OAuth challenge in "login-or-create" mode.
+ /// Returns 302 redirect to the provider authorization page.
+ ///
+ /// Provider key (e.g. discord).
+ /// Redirect to the provider authorization page.
+ /// Unsupported or misconfigured provider.
+ [EnableRateLimiting("auth")]
+ [HttpGet("{provider}/authorize")]
+ [ProducesResponseType(StatusCodes.Status302Found)]
+ [ProducesResponseType(StatusCodes.Status400BadRequest, MediaTypeNames.Application.Json)]
+ public async Task OAuthAuthorize([FromRoute] string provider)
+ {
+ if (!await _schemeProvider.IsSupportedOAuthScheme(provider))
+ return Problem(OAuthError.UnsupportedProvider);
+
+ if (User.HasOpenShockUserIdentity())
+ {
+ return Problem(OAuthError.AnonymousOnlyEndpoint);
+ }
+
+ return OAuthUtil.StartOAuth(provider, OAuthFlow.LoginOrCreate);
+ }
+}
\ No newline at end of file
diff --git a/API/Controller/OAuth/HandOff.cs b/API/Controller/OAuth/HandOff.cs
new file mode 100644
index 00000000..0e2cb322
--- /dev/null
+++ b/API/Controller/OAuth/HandOff.cs
@@ -0,0 +1,140 @@
+using Microsoft.AspNetCore.Authentication;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.RateLimiting;
+using OpenShock.API.Services.OAuthConnection;
+using OpenShock.Common.Options;
+using OpenShock.Common.Problems;
+using System.Net.Mime;
+using OpenShock.API.OAuth;
+using OpenShock.Common.Utils;
+
+namespace OpenShock.API.Controller.OAuth;
+
+public sealed partial class OAuthController
+{
+ ///
+ /// Handoff after provider callback. Decides next step (create, link, or direct sign-in).
+ ///
+ ///
+ /// Reads the temp OAuth flow principal (flow cookie set by middleware).
+ /// If a matching connection exists -> signs in and redirects home.
+ /// Otherwise -> redirects frontend to continue the chosen flow.
+ ///
+ [EnableRateLimiting("auth")]
+ [HttpGet("{provider}/handoff")]
+ [ProducesResponseType(StatusCodes.Status302Found)]
+ [ProducesResponseType(StatusCodes.Status400BadRequest, MediaTypeNames.Application.Json)]
+ public async Task OAuthHandOff(
+ [FromRoute] string provider,
+ [FromServices] IOAuthConnectionService connectionService,
+ [FromServices] FrontendOptions frontendOptions,
+ CancellationToken cancellationToken)
+ {
+ var result = await ValidateOAuthFlowAsync();
+ if (!result.TryPickT0(out var auth, out var error))
+ {
+ return error switch
+ {
+ OAuthValidationError.FlowStateMissing => RedirectFrontendError("OAuthFlowNotStarted"),
+ _ => RedirectFrontendError("InternalError")
+ };
+ }
+
+ // 1) Defense-in-depth: ensure the flow’s provider matches the route
+ if (!string.Equals(auth.Provider, provider, StringComparison.OrdinalIgnoreCase))
+ {
+ await HttpContext.SignOutAsync(OAuthConstants.FlowScheme);
+ return RedirectFrontendError("providerMismatch");
+ }
+
+ var connection = await connectionService
+ .GetByProviderExternalIdAsync(provider, auth.ExternalAccountId, cancellationToken);
+
+ switch (auth.Flow)
+ {
+ case OAuthFlow.LoginOrCreate:
+ {
+ if (User.HasOpenShockUserIdentity())
+ {
+ await HttpContext.SignOutAsync(OAuthConstants.FlowScheme);
+ return RedirectFrontendError("mustBeAnonymous");
+ }
+
+ if (connection is null)
+ {
+ // No connection -> continue to CREATE flow on frontend
+ return RedirectFrontendPath($"/oauth/{Uri.EscapeDataString(provider)}/create");
+ }
+
+ // Direct sign-in
+ var domain = GetCurrentCookieDomain();
+ if (string.IsNullOrEmpty(domain))
+ {
+ await HttpContext.SignOutAsync(OAuthConstants.FlowScheme);
+ return RedirectFrontendError("internalError");
+ }
+
+ await CreateSession(connection.UserId, domain);
+
+ // Flow cookie no longer needed
+ await HttpContext.SignOutAsync(OAuthConstants.FlowScheme);
+
+ return RedirectFrontendPath("/home");
+ }
+
+ case OAuthFlow.Link:
+ {
+ if (!User.TryGetAuthenticatedOpenShockUserId(out var userId))
+ {
+ await HttpContext.SignOutAsync(OAuthConstants.FlowScheme);
+ return RedirectFrontendError("mustBeAuthenticated");
+ }
+
+ if (connection is not null)
+ {
+ await HttpContext.SignOutAsync(OAuthConstants.FlowScheme);
+ return RedirectFrontendConnections(connection.UserId == userId ? "alreadyLinked" : "linkedToAnotherAccount");
+ }
+
+ var ok = await connectionService.TryAddConnectionAsync(userId, provider, auth.ExternalAccountId, auth.ExternalAccountDisplayName ?? auth.ExternalAccountName, cancellationToken);
+ if (!ok)
+ {
+ await HttpContext.SignOutAsync(OAuthConstants.FlowScheme);
+ return RedirectFrontendConnections("linkFailed");
+ }
+
+ // Direct sign-in
+ var domain = GetCurrentCookieDomain();
+ if (string.IsNullOrEmpty(domain))
+ {
+ await HttpContext.SignOutAsync(OAuthConstants.FlowScheme);
+ return RedirectFrontendConnections("internalError");
+ }
+
+ await CreateSession(userId, domain);
+
+ // Flow cookie no longer needed
+ await HttpContext.SignOutAsync(OAuthConstants.FlowScheme);
+
+ return RedirectFrontendConnections("linked");
+ }
+
+ default:
+ await HttpContext.SignOutAsync(OAuthConstants.FlowScheme);
+ return RedirectFrontendError("internalError");
+ }
+
+ // --- helpers ---
+
+ RedirectResult RedirectFrontendPath(string path)
+ {
+ return Redirect(new Uri(frontendOptions.BaseUrl, path).ToString());
+ }
+
+ RedirectResult RedirectFrontendError(string errorType)
+ => RedirectFrontendPath($"/oauth/error?error={Uri.EscapeDataString(errorType)}");
+
+ RedirectResult RedirectFrontendConnections(string statusType)
+ => RedirectFrontendPath($"/settings/connections?status={Uri.EscapeDataString(statusType)}");
+ }
+}
diff --git a/API/Controller/OAuth/ListProviders.cs b/API/Controller/OAuth/ListProviders.cs
new file mode 100644
index 00000000..be0631cf
--- /dev/null
+++ b/API/Controller/OAuth/ListProviders.cs
@@ -0,0 +1,20 @@
+using Microsoft.AspNetCore.Mvc;
+using OpenShock.API.OAuth;
+
+namespace OpenShock.API.Controller.OAuth;
+
+public sealed partial class OAuthController
+{
+ ///
+ /// Get the list of supported OAuth providers.
+ ///
+ ///
+ /// Returns the set of provider keys that are configured and available for use.
+ ///
+ /// Returns provider keys (e.g., discord).
+ [HttpGet("providers")]
+ public async Task ListOAuthProviders()
+ {
+ return await _schemeProvider.GetAllOAuthSchemesAsync();
+ }
+}
diff --git a/API/Controller/OAuth/SignupFinalize.cs b/API/Controller/OAuth/SignupFinalize.cs
new file mode 100644
index 00000000..67a8c97e
--- /dev/null
+++ b/API/Controller/OAuth/SignupFinalize.cs
@@ -0,0 +1,133 @@
+using Microsoft.AspNetCore.Authentication;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.RateLimiting;
+using OpenShock.API.Models.Requests;
+using OpenShock.API.Models.Response;
+using OpenShock.API.Services.OAuthConnection;
+using OpenShock.Common.Errors;
+using OpenShock.Common.Problems;
+using System.Net.Mime;
+using System.Security.Claims;
+using OpenShock.API.OAuth;
+using OpenShock.Common.Services.Session;
+using OpenShock.Common.Utils;
+
+namespace OpenShock.API.Controller.OAuth;
+
+public sealed partial class OAuthController
+{
+ ///
+ /// Finalize an OAuth flow by creating a new account with the external identity.
+ ///
+ ///
+ /// Authenticates via the temporary OAuth flow cookie (set during the provider callback).
+ /// Sets the regular session cookie on success. No access/refresh tokens are returned.
+ ///
+ /// Provider key (e.g. discord).
+ /// Request body containing optional Email and Username overrides.
+ ///
+ ///
+ /// Account created, external identity linked, and client authenticated.
+ /// Flow not found, missing data, or invalid username.
+ /// External already linked or username/email already exists.
+ [EnableRateLimiting("auth")]
+ [HttpPost("{provider}/signup-finalize")]
+ [ProducesResponseType(typeof(LoginV2OkResponse), StatusCodes.Status200OK, MediaTypeNames.Application.Json)]
+ [ProducesResponseType(typeof(OpenShockProblem), StatusCodes.Status400BadRequest, MediaTypeNames.Application.Json)]
+ [ProducesResponseType(typeof(OpenShockProblem), StatusCodes.Status409Conflict, MediaTypeNames.Application.Json)]
+ public async Task OAuthSignupFinalize(
+ [FromRoute] string provider,
+ [FromBody] OAuthFinalizeRequest body,
+ [FromServices] IOAuthConnectionService connectionService,
+ CancellationToken cancellationToken)
+ {
+ // If domain is not supported for cookies, cancel the flow
+ var domain = GetCurrentCookieDomain();
+ if (string.IsNullOrEmpty(domain))
+ {
+ await HttpContext.SignOutAsync(OAuthConstants.FlowScheme);
+ return Problem(OAuthError.InternalError);
+ }
+
+ var result = await ValidateOAuthFlowAsync();
+ if (!result.TryPickT0(out var auth, out var error))
+ {
+ return error switch
+ {
+ OAuthValidationError.FlowStateMissing => Problem(OAuthError.FlowNotFound),
+ _ => Problem(OAuthError.InternalError)
+ };
+ }
+
+ if (User.HasOpenShockUserIdentity())
+ {
+ return Problem(OAuthError.AnonymousOnlyEndpoint);
+ }
+
+ // 1) Defense-in-depth: ensure the flow’s provider matches the route
+ if (!string.Equals(auth.Provider, provider, StringComparison.OrdinalIgnoreCase) || auth.Flow != OAuthFlow.LoginOrCreate)
+ {
+ await HttpContext.SignOutAsync(OAuthConstants.FlowScheme);
+ return Problem(OAuthError.FlowMismatch);
+ }
+
+ // External identity basics from claims (added by your handler)
+ var externalAccountEmail = auth.Principal.FindFirst(ClaimTypes.Email)?.Value;
+ var username = body.Username ?? auth.ExternalAccountDisplayName ?? auth.ExternalAccountName;
+ var email = body.Email ?? externalAccountEmail;
+
+ if (string.IsNullOrWhiteSpace(auth.ExternalAccountId) || string.IsNullOrWhiteSpace(email) || string.IsNullOrWhiteSpace(username))
+ {
+ return Problem(OAuthError.FlowMissingData);
+ }
+
+ var isVerifiedString = auth.Principal.FindFirst(OAuthConstants.ClaimEmailVerified)?.Value;
+ var isEmailTrusted = IsTruthy(isVerifiedString) && string.Equals(externalAccountEmail, email, StringComparison.InvariantCultureIgnoreCase);
+
+ // Do not allow creation if this external is already linked anywhere.
+ var existing = await connectionService.GetByProviderExternalIdAsync(provider, auth.ExternalAccountId, cancellationToken);
+ if (existing is not null)
+ {
+ await HttpContext.SignOutAsync(OAuthConstants.FlowScheme);
+ return Problem(OAuthError.ExternalAlreadyLinked);
+ }
+
+ var created = await _accountService.CreateOAuthOnlyAccountAsync(
+ email,
+ username,
+ provider,
+ auth.ExternalAccountId,
+ auth.ExternalAccountDisplayName ?? auth.ExternalAccountName,
+ isEmailTrusted
+ );
+
+ if (!created.TryPickT0(out var newUser, out _))
+ {
+ // Username or email already exists — conflict.
+ // Do NOT clear the flow cookie so the frontend can retry with a different username.
+ return Problem(SignupError.UsernameOrEmailExists);
+ }
+
+ // Authenticate the client if its activated (create session and set session cookie)
+ if (newUser.Value.ActivatedAt is not null)
+ {
+ await CreateSession(newUser.Value.Id, domain);
+ }
+
+ // Clear the temporary OAuth flow cookie.
+ await HttpContext.SignOutAsync(OAuthConstants.FlowScheme);
+
+ return Ok(LoginV2OkResponse.FromUser(newUser.Value));
+
+ static bool IsTruthy(string? value)
+ {
+ if (string.IsNullOrWhiteSpace(value)) return false;
+ return value.Trim().ToLowerInvariant() switch
+ {
+ "0" or "no" or "false" => false,
+ "1" or "yes" or "true" => true,
+ _ => false
+ };
+ }
+ }
+}
diff --git a/API/Controller/OAuth/SignupGetData.cs b/API/Controller/OAuth/SignupGetData.cs
new file mode 100644
index 00000000..fdfe80af
--- /dev/null
+++ b/API/Controller/OAuth/SignupGetData.cs
@@ -0,0 +1,66 @@
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.RateLimiting;
+using OpenShock.API.Models.Response;
+using OpenShock.Common.Problems;
+using System.Net.Mime;
+using System.Security.Claims;
+using OpenShock.API.OAuth;
+using OpenShock.Common.Utils;
+
+namespace OpenShock.API.Controller.OAuth;
+
+public sealed partial class OAuthController
+{
+ ///
+ /// Provides temporary OAuth handoff details for the active signup flow.
+ ///
+ ///
+ /// Returns basic identity information from the external provider (e.g., email, display name)
+ /// together with the expiration time of the current flow.
+ /// Access to this endpoint requires the temporary OAuth flow cookie and is restricted to the user
+ /// who initiated the flow.
+ ///
+ /// The provider key (e.g. discord).
+ /// Handoff information returned successfully.
+ /// No active flow found, or the provider did not match.
+ [ResponseCache(NoStore = true)]
+ [EnableRateLimiting("auth")]
+ [HttpGet("{provider}/signup-data")]
+ [ProducesResponseType(StatusCodes.Status200OK, MediaTypeNames.Application.Json)]
+ [ProducesResponseType(StatusCodes.Status400BadRequest, MediaTypeNames.Application.Json)]
+ public async Task OAuthSignupGetData([FromRoute] string provider)
+ {
+ if (User.HasOpenShockUserIdentity())
+ {
+ return Problem(OAuthError.AnonymousOnlyEndpoint);
+ }
+
+ var result = await ValidateOAuthFlowAsync();
+ if (!result.TryPickT0(out var auth, out var error))
+ {
+ return error switch
+ {
+ OAuthValidationError.FlowStateMissing => Problem(OAuthError.FlowNotFound),
+ _ => Problem(OAuthError.InternalError)
+ };
+ }
+
+ if (auth.Provider != provider)
+ {
+ return Problem(OAuthError.ProviderMismatch);
+ }
+
+ if (auth.Flow != OAuthFlow.LoginOrCreate)
+ {
+ return Problem(OAuthError.FlowMismatch);
+ }
+
+ return Ok(new OAuthSignupDataResponse
+ {
+ Provider = auth.Provider,
+ Email = auth.Principal.FindFirst(ClaimTypes.Email)?.Value,
+ DisplayName = auth.Principal.FindFirst(ClaimTypes.Name)?.Value ?? auth.Principal.FindFirst(OAuthConstants.ClaimGlobalName)?.Value,
+ ExpiresAt = auth.Properties.ExpiresUtc!.Value.UtcDateTime
+ });
+ }
+}
diff --git a/API/Controller/OAuth/_ApiController.cs b/API/Controller/OAuth/_ApiController.cs
new file mode 100644
index 00000000..5da612d1
--- /dev/null
+++ b/API/Controller/OAuth/_ApiController.cs
@@ -0,0 +1,92 @@
+using System.Security.Claims;
+using Asp.Versioning;
+using Microsoft.AspNetCore.Authentication;
+using Microsoft.AspNetCore.Mvc;
+using OneOf;
+using OpenShock.API.OAuth;
+using OpenShock.API.Services.Account;
+using OpenShock.Common;
+
+namespace OpenShock.API.Controller.OAuth;
+
+///
+/// OAuth management endpoints (provider listing, authorize, data handoff).
+///
+[ApiController]
+[Tags("OAuth")]
+[ApiVersion("1")]
+[Route("/{version:apiVersion}/oauth")]
+public sealed partial class OAuthController : OpenShockControllerBase
+{
+ private readonly IAccountService _accountService;
+ private readonly IAuthenticationSchemeProvider _schemeProvider;
+ private readonly ILogger _logger;
+
+ public OAuthController(IAccountService accountService, IAuthenticationSchemeProvider schemeProvider, ILogger logger)
+ {
+ _accountService = accountService;
+ _schemeProvider = schemeProvider;
+ _logger = logger;
+ }
+
+ private enum OAuthValidationError
+ {
+ FlowStateMissing,
+ FlowDataMissingOrInvalid,
+ }
+
+ ///
+ /// Validates: provider exists, temp cookie auth present, scheme matches, flow parsable.
+ /// On success returns ValidatedFlowContext; on failure returns IActionResult with proper problem details.
+ ///
+ private async Task> ValidateOAuthFlowAsync()
+ {
+ // 1) authenticate temp cookie
+ var auth = await HttpContext.AuthenticateAsync(OAuthConstants.FlowScheme);
+ if (!auth.Succeeded || auth.Principal is null || auth.Ticket is null)
+ {
+ await HttpContext.SignOutAsync(OAuthConstants.FlowScheme);
+ return OAuthValidationError.FlowStateMissing;
+ }
+
+ // 2) scheme/provider check - prefer the ticket's scheme over a magic Item
+ if (!auth.Properties.Items.TryGetValue(".AuthScheme", out var actualScheme) || string.IsNullOrWhiteSpace(actualScheme))
+ {
+ _logger.LogError("Invalid OAuth scheme");
+ await HttpContext.SignOutAsync(OAuthConstants.FlowScheme);
+ return OAuthValidationError.FlowDataMissingOrInvalid;
+ }
+
+ // 3) parse flow from properties
+ if (auth.Properties is null ||
+ !auth.Properties.Items.TryGetValue(OAuthConstants.ItemKeyFlowType, out var flowStr) ||
+ !Enum.TryParse(flowStr, true, out var flow))
+ {
+ _logger.LogError("Invalid OAuth scheme");
+ await HttpContext.SignOutAsync(OAuthConstants.FlowScheme);
+ return OAuthValidationError.FlowDataMissingOrInvalid;
+ }
+
+ // 4) fetch id of external user
+ var externalId = auth.Principal.FindFirst(ClaimTypes.NameIdentifier)?.Value;
+ if (string.IsNullOrWhiteSpace(externalId))
+ {
+ _logger.LogError("Invalid OAuth scheme");
+ await HttpContext.SignOutAsync(OAuthConstants.FlowScheme);
+ return OAuthValidationError.FlowDataMissingOrInvalid;
+ }
+
+ string? displayName = auth.Principal.FindFirst(ClaimTypes.Name)?.Value;
+ string? globalName = auth.Principal.FindFirst(OAuthConstants.ClaimGlobalName)?.Value;
+
+ return new ValidatedFlowContext(
+ Provider: actualScheme,
+ Flow: flow,
+ ExternalAccountId: externalId,
+ ExternalAccountName: globalName ?? displayName,
+ ExternalAccountDisplayName: displayName ?? globalName,
+ Principal: auth.Principal,
+ Properties: auth.Properties
+ );
+ }
+}
\ No newline at end of file
diff --git a/API/Models/Requests/OAuthFinalizeRequest.cs b/API/Models/Requests/OAuthFinalizeRequest.cs
new file mode 100644
index 00000000..75044ba1
--- /dev/null
+++ b/API/Models/Requests/OAuthFinalizeRequest.cs
@@ -0,0 +1,19 @@
+using OpenShock.Common.DataAnnotations;
+
+namespace OpenShock.API.Models.Requests;
+
+public sealed class OAuthFinalizeRequest
+{
+ /// Desired username (create only). If omitted, a name will be generated from the external profile.
+ [Username(true)]
+ public required string? Username { get; init; }
+
+ [EmailAddress(true)]
+ public required string? Email { get; init; }
+
+ ///
+ /// New account password (create only).
+ ///
+ [Password(true)]
+ public required string? Password { get; init; }
+}
\ No newline at end of file
diff --git a/API/Models/Response/LoginV2OkResponse.cs b/API/Models/Response/LoginV2OkResponse.cs
index 6a487afb..763493c6 100644
--- a/API/Models/Response/LoginV2OkResponse.cs
+++ b/API/Models/Response/LoginV2OkResponse.cs
@@ -9,6 +9,7 @@ public sealed class LoginV2OkResponse
public required Guid AccountId { get; init; }
public required string AccountName { get; init; }
public required string AccountEmail { get; init; }
+ public required bool IsVerified { get; init; }
public required Uri ProfileImage { get; init; }
public required List AccountRoles { get; init; }
@@ -17,6 +18,7 @@ public sealed class LoginV2OkResponse
AccountId = argUser.Id,
AccountName = argUser.Name,
AccountEmail = argUser.Email,
+ IsVerified = argUser.ActivatedAt is not null,
ProfileImage = argUser.GetImageUrl(),
AccountRoles = argUser.Roles
};
diff --git a/API/Models/Response/OAuthConnectionResponse.cs b/API/Models/Response/OAuthConnectionResponse.cs
new file mode 100644
index 00000000..313b1bdc
--- /dev/null
+++ b/API/Models/Response/OAuthConnectionResponse.cs
@@ -0,0 +1,9 @@
+namespace OpenShock.API.Models.Response;
+
+public sealed class OAuthConnectionResponse
+{
+ public required string ProviderKey { get; init; }
+ public required string ExternalId { get; init; }
+ public required string? DisplayName { get; init; }
+ public required DateTime LinkedAt { get; init; }
+}
\ No newline at end of file
diff --git a/API/Models/Response/OAuthSignupDataResponse.cs b/API/Models/Response/OAuthSignupDataResponse.cs
new file mode 100644
index 00000000..98617473
--- /dev/null
+++ b/API/Models/Response/OAuthSignupDataResponse.cs
@@ -0,0 +1,10 @@
+namespace OpenShock.API.Models.Response;
+
+// what we return to frontend at /oauth/discord/data
+public sealed class OAuthSignupDataResponse
+{
+ public required string Provider { get; init; }
+ public required string? Email { get; init; }
+ public required string? DisplayName { get; init; }
+ public required DateTime ExpiresAt { get; init; }
+}
\ No newline at end of file
diff --git a/API/OAuth/AuthenticationSchemeProviderExtensions.cs b/API/OAuth/AuthenticationSchemeProviderExtensions.cs
new file mode 100644
index 00000000..5da2d4d9
--- /dev/null
+++ b/API/OAuth/AuthenticationSchemeProviderExtensions.cs
@@ -0,0 +1,25 @@
+using Microsoft.AspNetCore.Authentication;
+
+namespace OpenShock.API.OAuth;
+
+public static class AuthenticationSchemeProviderExtensions
+{
+ public static async Task GetAllOAuthSchemesAsync(this IAuthenticationSchemeProvider provider)
+ {
+ var schemes = await provider.GetAllSchemesAsync();
+
+ return schemes
+ .Select(scheme => scheme.Name)
+ .Where(scheme => OAuthConstants.OAuth2Schemes.Contains(scheme))
+ .ToArray();
+ }
+ public static async Task IsSupportedOAuthScheme(this IAuthenticationSchemeProvider provider, string scheme)
+ {
+ if (!OAuthConstants.OAuth2Schemes.Contains(scheme))
+ return false;
+
+ var schemes = await provider.GetAllSchemesAsync();
+
+ return schemes.Any(s => s.Name == scheme);
+ }
+}
diff --git a/API/OAuth/OAuthConstants.cs b/API/OAuth/OAuthConstants.cs
new file mode 100644
index 00000000..0ca755c3
--- /dev/null
+++ b/API/OAuth/OAuthConstants.cs
@@ -0,0 +1,15 @@
+namespace OpenShock.API.OAuth;
+
+public static class OAuthConstants
+{
+ public const string FlowScheme = "OAuthFlowCookie";
+ public const string FlowCookieName = ".OpenShock.OAuthFlow";
+
+ public const string DiscordScheme = "discord";
+ public static readonly string[] OAuth2Schemes = [DiscordScheme];
+
+ public const string ItemKeyFlowType = ".FlowType";
+
+ public const string ClaimEmailVerified = "openshock.oauth.email_verified";
+ public const string ClaimGlobalName = "openshock.oauth.global_name";
+}
\ No newline at end of file
diff --git a/API/OAuth/OAuthError.cs b/API/OAuth/OAuthError.cs
new file mode 100644
index 00000000..6f042505
--- /dev/null
+++ b/API/OAuth/OAuthError.cs
@@ -0,0 +1,66 @@
+using System.Net;
+using OpenShock.Common.Problems;
+
+namespace OpenShock.API.OAuth;
+
+public static class OAuthError
+{
+ // Provider-related
+ public static OpenShockProblem UnsupportedProvider => new(
+ "OAuth.Provider.Unsupported",
+ "The requested OAuth provider is not supported",
+ HttpStatusCode.BadRequest);
+
+ public static OpenShockProblem ProviderMismatch => new(
+ "OAuth.Provider.Mismatch",
+ "The current OAuth flow does not match the requested provider",
+ HttpStatusCode.BadRequest);
+
+ // Flow-related
+ public static OpenShockProblem UnsupportedFlow => new(
+ "OAuth.Flow.Unsupported",
+ "This OAuth flow type is not recognized or allowed",
+ HttpStatusCode.Forbidden);
+
+ public static OpenShockProblem FlowMismatch => new(
+ "OAuth.Flow.Mismatch",
+ "This OAuth flow differs from the flow the oauth flow started with",
+ HttpStatusCode.Forbidden);
+
+ public static OpenShockProblem AnonymousOnlyEndpoint => new(
+ "OAuth.Flow.AnonymousOnlyEndpoint",
+ "You must be signed out to call this endpoint",
+ HttpStatusCode.Unauthorized);
+
+ public static OpenShockProblem FlowNotFound => new(
+ "OAuth.Flow.NotFound",
+ "The OAuth flow was not found, has expired, or is invalid",
+ HttpStatusCode.BadRequest);
+
+ public static OpenShockProblem FlowMissingData => new(
+ "OAuth.Flow.MissingData",
+ "The OAuth provider did not supply the expected identity data",
+ HttpStatusCode.BadGateway); // 502 makes sense if external didn't return what we expect
+
+ // Connection-related
+ public static OpenShockProblem ConnectionAlreadyExists => new(
+ "OAuth.Connection.AlreadyExists",
+ "Your account already has an OAuth connection for this provider",
+ HttpStatusCode.Conflict);
+
+ public static OpenShockProblem ExternalAlreadyLinked => new(
+ "OAuth.Connection.AlreadyLinked",
+ "This external account is already linked to another user",
+ HttpStatusCode.Conflict);
+
+ public static OpenShockProblem NotAuthenticatedForLink => new(
+ "OAuth.Link.NotAuthenticated",
+ "You must be signed in to link an external account",
+ HttpStatusCode.Unauthorized);
+
+ // Misc / generic
+ public static OpenShockProblem InternalError => new(
+ "OAuth.InternalError",
+ "An unexpected error occurred while processing the OAuth flow",
+ HttpStatusCode.InternalServerError);
+}
\ No newline at end of file
diff --git a/API/OAuth/OAuthFlow.cs b/API/OAuth/OAuthFlow.cs
new file mode 100644
index 00000000..2caf75c4
--- /dev/null
+++ b/API/OAuth/OAuthFlow.cs
@@ -0,0 +1,7 @@
+namespace OpenShock.API.OAuth;
+
+public enum OAuthFlow
+{
+ LoginOrCreate,
+ Link
+}
\ No newline at end of file
diff --git a/API/OAuth/OAuthUtil.cs b/API/OAuth/OAuthUtil.cs
new file mode 100644
index 00000000..5ae53549
--- /dev/null
+++ b/API/OAuth/OAuthUtil.cs
@@ -0,0 +1,16 @@
+using Microsoft.AspNetCore.Authentication;
+using Microsoft.AspNetCore.Mvc;
+
+namespace OpenShock.API.OAuth;
+
+public static class OAuthUtil
+{
+ public static ChallengeResult StartOAuth(string provider, OAuthFlow flow)
+ {
+ return new ChallengeResult(provider, new AuthenticationProperties
+ {
+ RedirectUri = $"/1/oauth/{provider}/handoff",
+ Items = { { OAuthConstants.ItemKeyFlowType, flow.ToString() } }
+ });
+ }
+}
\ No newline at end of file
diff --git a/API/OAuth/ValidatedFlowContext.cs b/API/OAuth/ValidatedFlowContext.cs
new file mode 100644
index 00000000..3bca965f
--- /dev/null
+++ b/API/OAuth/ValidatedFlowContext.cs
@@ -0,0 +1,6 @@
+using System.Security.Claims;
+using Microsoft.AspNetCore.Authentication;
+
+namespace OpenShock.API.OAuth;
+
+public sealed record ValidatedFlowContext(string Provider, OAuthFlow Flow, string ExternalAccountId, string? ExternalAccountName, string? ExternalAccountDisplayName, ClaimsPrincipal Principal, AuthenticationProperties Properties);
\ No newline at end of file
diff --git a/API/Options/OAuth/DiscordOAuthOptions.cs b/API/Options/OAuth/DiscordOAuthOptions.cs
new file mode 100644
index 00000000..b70c969e
--- /dev/null
+++ b/API/Options/OAuth/DiscordOAuthOptions.cs
@@ -0,0 +1,11 @@
+
+
+namespace OpenShock.API.Options.OAuth;
+
+public sealed class DiscordOAuthOptions
+{
+ public const string SectionName = "OpenShock:OAuth2:Discord";
+
+ public required string ClientId { get; init; }
+ public required string ClientSecret { get; init; }
+}
\ No newline at end of file
diff --git a/API/Program.cs b/API/Program.cs
index 129fde32..7542829b 100644
--- a/API/Program.cs
+++ b/API/Program.cs
@@ -1,8 +1,12 @@
+using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Http.Connections;
+using OpenShock.API.OAuth;
+using OpenShock.API.Options.OAuth;
using OpenShock.API.Realtime;
using OpenShock.API.Services.Account;
using OpenShock.API.Services.DeviceUpdate;
using OpenShock.API.Services.Email;
+using OpenShock.API.Services.OAuthConnection;
using OpenShock.API.Services.Turnstile;
using OpenShock.API.Services.UserService;
using OpenShock.Common;
@@ -26,7 +30,38 @@
builder.Services
.AddOpenShockMemDB(redisOptions)
.AddOpenShockDB(databaseOptions)
- .AddOpenShockServices()
+ .AddOpenShockServices(auth =>
+ {
+ auth.AddCookie(OAuthConstants.FlowScheme, o => {
+ o.Cookie.Name = OAuthConstants.FlowCookieName;
+ o.ExpireTimeSpan = TimeSpan.FromMinutes(10);
+ o.SlidingExpiration = false;
+ });
+
+ var options = builder.Configuration.GetSection(DiscordOAuthOptions.SectionName).Get();
+ if (options is not null)
+ {
+ auth.AddDiscord(OAuthConstants.DiscordScheme, o => {
+ o.SignInScheme = OAuthConstants.FlowScheme;
+
+
+
+ o.ClientId = options.ClientId;
+ o.ClientSecret = options.ClientSecret;
+ o.CallbackPath = "/oauth/discord/callback";
+ o.CallbackPath = "/oauth/discord/rejected"; // TODO: Make this do something
+ o.Scope.Add("email");
+
+ o.Prompt = "none";
+ o.SaveTokens = false;
+
+ o.ClaimActions.MapJsonKey(OAuthConstants.ClaimEmailVerified, "verified");
+ o.ClaimActions.MapJsonKey(OAuthConstants.ClaimGlobalName, "global_name");
+
+ o.Validate();
+ });
+ }
+ })
.AddOpenShockSignalR(redisOptions);
builder.Services.AddScoped();
@@ -34,6 +69,7 @@
builder.Services.AddScoped();
builder.Services.AddScoped();
builder.Services.AddScoped();
+builder.Services.AddScoped();
builder.Services.AddScoped();
builder.Services.AddScoped();
diff --git a/API/Services/Account/AccountService.cs b/API/Services/Account/AccountService.cs
index 054d6d7e..0f527a48 100644
--- a/API/Services/Account/AccountService.cs
+++ b/API/Services/Account/AccountService.cs
@@ -1,6 +1,7 @@
using System.Net.Mail;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
+using Npgsql;
using OneOf;
using OneOf.Types;
using OpenShock.API.Services.Email;
@@ -122,6 +123,94 @@ await _emailService.VerifyEmail(new Contact(email, username),
return new Success(user);
}
+ public async Task, AccountWithEmailOrUsernameExists>> CreateOAuthOnlyAccountAsync(
+ string email,
+ string username,
+ string provider,
+ string providerAccountId,
+ string? providerAccountName,
+ bool isEmailTrusted)
+ {
+ email = email.ToLowerInvariant();
+ provider = provider.ToLowerInvariant();
+
+ // Reuse your existing guards
+ if (await IsUserNameBlacklisted(username) || await IsEmailProviderBlacklisted(email))
+ return new AccountWithEmailOrUsernameExists();
+
+ // Fast uniqueness check (optimistic; race handled by unique constraints below)
+ var exists = await _db.Users.AnyAsync(u => u.Email == email || u.Name == username);
+ if (exists) return new AccountWithEmailOrUsernameExists();
+
+ await using var tx = await _db.Database.BeginTransactionAsync();
+
+ string? activationToken = null;
+
+ try
+ {
+ var creationTime = DateTime.UtcNow;
+
+ var user = new User
+ {
+ Id = Guid.CreateVersion7(),
+ Name = username,
+ Email = email,
+ PasswordHash = null,
+ CreatedAt = creationTime,
+ ActivatedAt = isEmailTrusted ? creationTime : null
+ };
+
+ _db.Users.Add(user);
+ await _db.SaveChangesAsync();
+
+ // If email isn't trusted, create an activation request (email verification)
+ if (!isEmailTrusted)
+ {
+ activationToken = CryptoUtils.RandomString(AuthConstants.GeneratedTokenLength);
+
+ user.UserActivationRequest = new UserActivationRequest
+ {
+ UserId = user.Id,
+ TokenHash = HashingUtils.HashToken(activationToken),
+ CreatedAt = creationTime
+ };
+
+ await _db.SaveChangesAsync();
+ }
+
+ // Link external identity
+ _db.UserOAuthConnections.Add(new UserOAuthConnection
+ {
+ UserId = user.Id,
+ ProviderKey = provider,
+ ExternalId = providerAccountId,
+ DisplayName = providerAccountName,
+ CreatedAt = creationTime,
+ });
+
+ await _db.SaveChangesAsync();
+
+ await tx.CommitAsync();
+
+ // Send verification email only after successful commit
+ if (!isEmailTrusted && activationToken is not null)
+ {
+ await _emailService.VerifyEmail(
+ new Contact(email, username),
+ new Uri(_frontendConfig.BaseUrl, $"/#/account/activate/{user.Id}/{activationToken}")
+ );
+ }
+
+ return new Success(user);
+ }
+ catch (DbUpdateException ex) when (ex.InnerException is PostgresException { SqlState: "23505" })
+ {
+ await tx.RollbackAsync();
+ return new AccountWithEmailOrUsernameExists();
+ }
+ }
+
+
public async Task TryActivateAccountAsync(string secret, CancellationToken cancellationToken = default)
{
var hash = HashingUtils.HashToken(secret);
@@ -249,12 +338,12 @@ public async Task
- public async Task> CreateUserLoginSessionAsync(string usernameOrEmail, string password,
- LoginContext loginContext, CancellationToken cancellationToken = default)
+ ///
+ public async Task> GetAccountByCredentialsAsync(string usernameOrEmail, string password, CancellationToken cancellationToken)
{
var lowercaseUsernameOrEmail = usernameOrEmail.ToLowerInvariant();
var user = await _db.Users
+ .AsNoTracking()
.Include(u => u.UserDeactivation)
.FirstOrDefaultAsync(x => x.Email == lowercaseUsernameOrEmail || x.Name == lowercaseUsernameOrEmail, cancellationToken);
if (user is null)
@@ -262,25 +351,29 @@ public async Task
@@ -447,6 +540,11 @@ public async Task TryVerifyEmailAsync(string token, CancellationToken canc
private async Task CheckPassword(string password, User user)
{
+ if (string.IsNullOrEmpty(user.PasswordHash))
+ {
+ return false;
+ }
+
var result = HashingUtils.VerifyPassword(password, user.PasswordHash);
if (!result.Verified)
diff --git a/API/Services/Account/IAccountService.cs b/API/Services/Account/IAccountService.cs
index 52e74fa1..c7d22cd5 100644
--- a/API/Services/Account/IAccountService.cs
+++ b/API/Services/Account/IAccountService.cs
@@ -28,6 +28,20 @@ public interface IAccountService
///
public Task, AccountWithEmailOrUsernameExists>> CreateAccountWithActivationFlowAsync(string email, string username, string password);
+ ///
+ /// Creates an OAuth-only (passwordless) account and links the external identity in a single transaction.
+ /// The new user is activated immediately (no activation flow). Returns a conflict-style result if the
+ /// username/email is taken or the external identity is already linked.
+ ///
+ /// Email to set on the user.
+ /// Desired unique username.
+ /// e.g. "discord"
+ /// external subject/id from provider
+ /// display name from provider
+ ///
+ /// Success with the created user, or AccountWithEmailOrUsernameExists when taken/blocked.
+ Task, AccountWithEmailOrUsernameExists>> CreateOAuthOnlyAccountAsync(string email, string username, string provider, string providerAccountId, string? providerAccountName, bool isEmailTrusted);
+
///
///
///
@@ -43,15 +57,14 @@ public interface IAccountService
public Task> DeleteAccountAsync(Guid executingUserId, Guid userId);
///
- /// Login a user into his user session
+ /// Get a user by credentials
///
///
///
- ///
///
///
- public Task> CreateUserLoginSessionAsync(string usernameOrEmail, string password, LoginContext loginContext, CancellationToken cancellationToken = default);
-
+ public Task> GetAccountByCredentialsAsync(string usernameOrEmail, string password, CancellationToken cancellationToken = default);
+
///
/// Check if a password reset request exists and the secret is valid
///
@@ -113,8 +126,9 @@ public interface IAccountService
}
public sealed record CreateUserLoginSessionSuccess(User User, string Token);
-public readonly record struct AccountNotActivated;
-public readonly record struct AccountDeactivated;
+public readonly struct AccountIsOAuthOnly;
+public readonly struct AccountNotActivated;
+public readonly struct AccountDeactivated;
public readonly struct AccountWithEmailOrUsernameExists;
public readonly struct CannotDeactivatePrivilegedAccount;
public readonly struct AccountDeactivationAlreadyInProgress;
diff --git a/API/Services/OAuthConnection/IOAuthConnectionService.cs b/API/Services/OAuthConnection/IOAuthConnectionService.cs
new file mode 100644
index 00000000..ab19d5eb
--- /dev/null
+++ b/API/Services/OAuthConnection/IOAuthConnectionService.cs
@@ -0,0 +1,16 @@
+using OpenShock.Common.OpenShockDb;
+
+namespace OpenShock.API.Services.OAuthConnection;
+
+///
+/// Manages external OAuth connections for users.
+///
+public interface IOAuthConnectionService
+{
+ Task GetConnectionsAsync(Guid userId, CancellationToken cancellationToken = default);
+ Task GetByProviderExternalIdAsync(string provider, string providerAccountId, CancellationToken cancellationToken = default);
+ Task ConnectionExistsAsync(string provider, string providerAccountId, CancellationToken cancellationToken = default);
+ Task HasConnectionAsync(Guid userId, string provider, CancellationToken cancellationToken = default);
+ Task TryAddConnectionAsync(Guid userId, string provider, string providerAccountId, string? providerAccountName, CancellationToken cancellationToken = default);
+ Task TryRemoveConnectionAsync(Guid userId, string provider, CancellationToken cancellationToken = default);
+}
\ No newline at end of file
diff --git a/API/Services/OAuthConnection/OAuthConnectionService.cs b/API/Services/OAuthConnection/OAuthConnectionService.cs
new file mode 100644
index 00000000..10170b02
--- /dev/null
+++ b/API/Services/OAuthConnection/OAuthConnectionService.cs
@@ -0,0 +1,76 @@
+using Microsoft.EntityFrameworkCore;
+using Npgsql;
+using OpenShock.Common.OpenShockDb;
+
+namespace OpenShock.API.Services.OAuthConnection;
+
+public sealed class OAuthConnectionService : IOAuthConnectionService
+{
+ private readonly OpenShockContext _db;
+ private readonly ILogger _logger;
+
+ public OAuthConnectionService(OpenShockContext db, ILogger logger)
+ {
+ _db = db;
+ _logger = logger;
+ }
+
+ public async Task GetConnectionsAsync(Guid userId, CancellationToken cancellationToken)
+ {
+ return await _db.UserOAuthConnections
+ .AsNoTracking()
+ .Where(c => c.UserId == userId)
+ .ToArrayAsync(cancellationToken);
+ }
+
+ public async Task GetByProviderExternalIdAsync(string provider, string providerAccountId, CancellationToken cancellationToken)
+ {
+ var p = provider.ToLowerInvariant();
+ return await _db.UserOAuthConnections
+ .FirstOrDefaultAsync(c => c.ProviderKey == p && c.ExternalId == providerAccountId, cancellationToken);
+ }
+
+ public async Task ConnectionExistsAsync(string provider, string providerAccountId, CancellationToken cancellationToken)
+ {
+ var p = provider.ToLowerInvariant();
+ return await _db.UserOAuthConnections.AnyAsync(c => c.ProviderKey == p && c.ExternalId == providerAccountId, cancellationToken);
+ }
+
+ public async Task HasConnectionAsync(Guid userId, string provider, CancellationToken cancellationToken)
+ {
+ var p = provider.ToLowerInvariant();
+ return await _db.UserOAuthConnections.AnyAsync(c => c.UserId == userId && c.ProviderKey == p, cancellationToken);
+ }
+
+ public async Task TryAddConnectionAsync(Guid userId, string provider, string providerAccountId, string? providerAccountName, CancellationToken cancellationToken)
+ {
+ try
+ {
+ _db.UserOAuthConnections.Add(new UserOAuthConnection
+ {
+ UserId = userId,
+ ProviderKey = provider.ToLowerInvariant(),
+ ExternalId = providerAccountId,
+ DisplayName = providerAccountName
+ });
+ await _db.SaveChangesAsync(cancellationToken);
+ return true;
+ }
+ catch (DbUpdateException ex) when (ex.InnerException is PostgresException { SqlState: "23505" })
+ {
+ // Unique constraint violation (duplicate link)
+ _logger.LogDebug(ex, "Duplicate OAuth link for {Provider}:{ExternalId}", provider, providerAccountId);
+ return false;
+ }
+ }
+
+ public async Task TryRemoveConnectionAsync(Guid userId, string provider, CancellationToken cancellationToken)
+ {
+ var p = provider.ToLowerInvariant();
+ var nDeleted = await _db.UserOAuthConnections
+ .Where(c => c.UserId == userId && c.ProviderKey == p)
+ .ExecuteDeleteAsync(cancellationToken);
+
+ return nDeleted > 0;
+ }
+}
diff --git a/Common/Common.csproj b/Common/Common.csproj
index 063e25d2..4f8349fe 100644
--- a/Common/Common.csproj
+++ b/Common/Common.csproj
@@ -13,6 +13,7 @@
runtime; build; native; contentfiles; analyzers; buildtransitive
+
diff --git a/Common/Errors/AccountError.cs b/Common/Errors/AccountError.cs
index aca57860..03429815 100644
--- a/Common/Errors/AccountError.cs
+++ b/Common/Errors/AccountError.cs
@@ -27,4 +27,6 @@ public static class AccountError
public static OpenShockProblem AccountNotActivated => new OpenShockProblem("Account.AccountNotActivated", "Your account has not been activated", HttpStatusCode.Unauthorized);
public static OpenShockProblem AccountDeactivated => new OpenShockProblem("Account.Deactivated", "Your account has been deactivated", HttpStatusCode.Unauthorized);
+
+ public static OpenShockProblem AccountOAuthOnly => new OpenShockProblem("Account.OAuthOnly", "This account is only accessible via OAuth", HttpStatusCode.Unauthorized);
}
\ No newline at end of file
diff --git a/Common/Errors/LoginError.cs b/Common/Errors/LoginError.cs
index 8185ce98..d6f8d10b 100644
--- a/Common/Errors/LoginError.cs
+++ b/Common/Errors/LoginError.cs
@@ -5,6 +5,6 @@ namespace OpenShock.Common.Errors;
public static class LoginError
{
- public static OpenShockProblem InvalidCredentials => new OpenShockProblem("Login.InvalidCredentials", "Invalid credentials provided", HttpStatusCode.Unauthorized);
+ public static OpenShockProblem InvalidCredentials => new OpenShockProblem("Login.InvalidCredentials", "Invalid username or password", HttpStatusCode.Unauthorized);
public static OpenShockProblem InvalidDomain => new OpenShockProblem("Login.InvalidDomain", "The url you are requesting a login from is not whitelisted", HttpStatusCode.Forbidden);
}
\ No newline at end of file
diff --git a/Common/Errors/SignupError.cs b/Common/Errors/SignupError.cs
index 8e8841b1..66535001 100644
--- a/Common/Errors/SignupError.cs
+++ b/Common/Errors/SignupError.cs
@@ -5,5 +5,8 @@ namespace OpenShock.Common.Errors;
public static class SignupError
{
- public static OpenShockProblem EmailAlreadyExists => new("Signup.EmailOrUsernameAlreadyExists", "Email or username already exists", HttpStatusCode.Conflict);
+ public static OpenShockProblem UsernameOrEmailExists => new(
+ "Signup.UsernameOrEmailExists",
+ "The chosen username or email is already in use",
+ HttpStatusCode.Conflict);
}
\ No newline at end of file
diff --git a/Common/Migrations/20250903235304_AddOAuthSupport.Designer.cs b/Common/Migrations/20250903235304_AddOAuthSupport.Designer.cs
new file mode 100644
index 00000000..64b05549
--- /dev/null
+++ b/Common/Migrations/20250903235304_AddOAuthSupport.Designer.cs
@@ -0,0 +1,1464 @@
+//
+using System;
+using System.Collections.Generic;
+using System.Net;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+using OpenShock.Common.Models;
+using OpenShock.Common.OpenShockDb;
+
+#nullable disable
+
+namespace OpenShock.Common.Migrations
+{
+ [DbContext(typeof(MigrationOpenShockContext))]
+ [Migration("20250903235304_AddOAuthSupport")]
+ partial class AddOAuthSupport
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("Npgsql:CollationDefinition:public.ndcoll", "und-u-ks-level2,und-u-ks-level2,icu,False")
+ .HasAnnotation("ProductVersion", "9.0.8")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "configuration_value_type", new[] { "string", "bool", "int", "float", "json" });
+ NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "control_type", new[] { "sound", "vibrate", "shock", "stop" });
+ NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "match_type_enum", new[] { "exact", "contains" });
+ NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "ota_update_status", new[] { "started", "running", "finished", "error", "timeout" });
+ NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "password_encryption_type", new[] { "pbkdf2", "bcrypt_enhanced" });
+ NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "permission_type", new[] { "shockers.use", "shockers.edit", "shockers.pause", "devices.edit", "devices.auth" });
+ NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "role_type", new[] { "support", "staff", "admin", "system" });
+ NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "shocker_model_type", new[] { "caiXianlin", "petTrainer", "petrainer998DR" });
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("Microsoft.AspNetCore.DataProtection.EntityFrameworkCore.DataProtectionKey", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("FriendlyName")
+ .HasColumnType("text");
+
+ b.Property("Xml")
+ .HasColumnType("text");
+
+ b.HasKey("Id");
+
+ b.ToTable("DataProtectionKeys");
+ });
+
+ modelBuilder.Entity("OpenShock.Common.OpenShockDb.AdminUsersView", b =>
+ {
+ b.Property("ActivatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("activated_at");
+
+ b.Property("ApiTokenCount")
+ .HasColumnType("integer")
+ .HasColumnName("api_token_count");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property("DeactivatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("deactivated_at");
+
+ b.Property("DeactivatedByUserId")
+ .HasColumnType("uuid")
+ .HasColumnName("deactivated_by_user_id");
+
+ b.Property("DeviceCount")
+ .HasColumnType("integer")
+ .HasColumnName("device_count");
+
+ b.Property("Email")
+ .IsRequired()
+ .HasColumnType("character varying")
+ .HasColumnName("email");
+
+ b.Property("EmailChangeRequestCount")
+ .HasColumnType("integer")
+ .HasColumnName("email_change_request_count");
+
+ b.Property("Id")
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasColumnType("character varying")
+ .HasColumnName("name");
+
+ b.Property("NameChangeRequestCount")
+ .HasColumnType("integer")
+ .HasColumnName("name_change_request_count");
+
+ b.Property("PasswordHashType")
+ .IsRequired()
+ .HasColumnType("character varying")
+ .HasColumnName("password_hash_type");
+
+ b.Property("PasswordResetCount")
+ .HasColumnType("integer")
+ .HasColumnName("password_reset_count");
+
+ b.Property("Roles")
+ .IsRequired()
+ .HasColumnType("role_type[]")
+ .HasColumnName("roles");
+
+ b.Property("ShockerControlLogCount")
+ .HasColumnType("integer")
+ .HasColumnName("shocker_control_log_count");
+
+ b.Property("ShockerCount")
+ .HasColumnType("integer")
+ .HasColumnName("shocker_count");
+
+ b.Property("ShockerPublicShareCount")
+ .HasColumnType("integer")
+ .HasColumnName("shocker_public_share_count");
+
+ b.Property("ShockerUserShareCount")
+ .HasColumnType("integer")
+ .HasColumnName("shocker_user_share_count");
+
+ b.ToTable((string)null);
+
+ b.ToView("admin_users_view", (string)null);
+ });
+
+ modelBuilder.Entity("OpenShock.Common.OpenShockDb.ApiToken", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("CreatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at")
+ .HasDefaultValueSql("CURRENT_TIMESTAMP");
+
+ b.Property("CreatedByIp")
+ .IsRequired()
+ .HasColumnType("inet")
+ .HasColumnName("created_by_ip");
+
+ b.Property("LastUsed")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("last_used")
+ .HasDefaultValueSql("'-infinity'::timestamp without time zone");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)")
+ .HasColumnName("name");
+
+ b.PrimitiveCollection>("Permissions")
+ .IsRequired()
+ .HasColumnType("permission_type[]")
+ .HasColumnName("permissions");
+
+ b.Property("TokenHash")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)")
+ .HasColumnName("token_hash")
+ .UseCollation("C");
+
+ b.Property("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.Property("ValidUntil")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("valid_until");
+
+ b.HasKey("Id")
+ .HasName("api_tokens_pkey");
+
+ b.HasIndex("TokenHash")
+ .IsUnique();
+
+ b.HasIndex("UserId");
+
+ b.HasIndex("ValidUntil");
+
+ b.ToTable("api_tokens", (string)null);
+ });
+
+ modelBuilder.Entity("OpenShock.Common.OpenShockDb.ApiTokenReport", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("AffectedCount")
+ .HasColumnType("integer")
+ .HasColumnName("affected_count");
+
+ b.Property("CreatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at")
+ .HasDefaultValueSql("CURRENT_TIMESTAMP");
+
+ b.Property("IpAddress")
+ .IsRequired()
+ .HasColumnType("inet")
+ .HasColumnName("ip_address");
+
+ b.Property("IpCountry")
+ .HasColumnType("text")
+ .HasColumnName("ip_country");
+
+ b.Property("SubmittedCount")
+ .HasColumnType("integer")
+ .HasColumnName("submitted_count");
+
+ b.Property("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("Id")
+ .HasName("api_token_reports_pkey");
+
+ b.HasIndex("UserId");
+
+ b.ToTable("api_token_reports", (string)null);
+ });
+
+ modelBuilder.Entity("OpenShock.Common.OpenShockDb.ConfigurationItem", b =>
+ {
+ b.Property("Name")
+ .HasColumnType("text")
+ .HasColumnName("name")
+ .UseCollation("C");
+
+ b.Property("CreatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at")
+ .HasDefaultValueSql("CURRENT_TIMESTAMP");
+
+ b.Property("Description")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("description");
+
+ b.Property("Type")
+ .HasColumnType("configuration_value_type")
+ .HasColumnName("type");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("updated_at");
+
+ b.Property("Value")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("value");
+
+ b.HasKey("Name")
+ .HasName("configuration_pkey");
+
+ b.ToTable("configuration", (string)null);
+ });
+
+ modelBuilder.Entity("OpenShock.Common.OpenShockDb.Device", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("CreatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at")
+ .HasDefaultValueSql("CURRENT_TIMESTAMP");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)")
+ .HasColumnName("name");
+
+ b.Property("OwnerId")
+ .HasColumnType("uuid")
+ .HasColumnName("owner_id");
+
+ b.Property("Token")
+ .IsRequired()
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)")
+ .HasColumnName("token")
+ .UseCollation("C");
+
+ b.HasKey("Id")
+ .HasName("devices_pkey");
+
+ b.HasIndex("OwnerId");
+
+ b.HasIndex("Token")
+ .IsUnique();
+
+ b.ToTable("devices", (string)null);
+ });
+
+ modelBuilder.Entity("OpenShock.Common.OpenShockDb.DeviceOtaUpdate", b =>
+ {
+ b.Property("DeviceId")
+ .HasColumnType("uuid")
+ .HasColumnName("device_id");
+
+ b.Property("UpdateId")
+ .HasColumnType("integer")
+ .HasColumnName("update_id");
+
+ b.Property("CreatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at")
+ .HasDefaultValueSql("CURRENT_TIMESTAMP");
+
+ b.Property("Message")
+ .HasMaxLength(128)
+ .HasColumnType("character varying(128)")
+ .HasColumnName("message");
+
+ b.Property("Status")
+ .HasColumnType("ota_update_status")
+ .HasColumnName("status");
+
+ b.Property("Version")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)")
+ .HasColumnName("version");
+
+ b.HasKey("DeviceId", "UpdateId")
+ .HasName("device_ota_updates_pkey");
+
+ b.HasIndex(new[] { "CreatedAt" }, "device_ota_updates_created_at_idx");
+
+ b.ToTable("device_ota_updates", (string)null);
+ });
+
+ modelBuilder.Entity("OpenShock.Common.OpenShockDb.DiscordWebhook", b =>
+ {
+ b.Property("Name")
+ .HasColumnType("text")
+ .HasColumnName("name");
+
+ b.Property("CreatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at")
+ .HasDefaultValueSql("CURRENT_TIMESTAMP");
+
+ b.Property("Id")
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("WebhookId")
+ .HasColumnType("bigint")
+ .HasColumnName("webhook_id");
+
+ b.Property("WebhookToken")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("webhook_token");
+
+ b.HasKey("Name")
+ .HasName("discord_webhooks_pkey");
+
+ b.ToTable("discord_webhooks", (string)null);
+ });
+
+ modelBuilder.Entity("OpenShock.Common.OpenShockDb.EmailProviderBlacklist", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("CreatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at")
+ .HasDefaultValueSql("CURRENT_TIMESTAMP");
+
+ b.Property("Domain")
+ .IsRequired()
+ .HasMaxLength(255)
+ .HasColumnType("character varying(255)")
+ .HasColumnName("domain")
+ .UseCollation("ndcoll");
+
+ b.HasKey("Id")
+ .HasName("email_provider_blacklist_pkey");
+
+ b.HasIndex("Domain")
+ .IsUnique();
+
+ NpgsqlIndexBuilderExtensions.UseCollation(b.HasIndex("Domain"), new[] { "ndcoll" });
+
+ b.ToTable("email_provider_blacklist", (string)null);
+ });
+
+ modelBuilder.Entity("OpenShock.Common.OpenShockDb.PublicShare", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("CreatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at")
+ .HasDefaultValueSql("CURRENT_TIMESTAMP");
+
+ b.Property("ExpiresAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("expires_at");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)")
+ .HasColumnName("name");
+
+ b.Property("OwnerId")
+ .HasColumnType("uuid")
+ .HasColumnName("owner_id");
+
+ b.HasKey("Id")
+ .HasName("public_shares_pkey");
+
+ b.HasIndex("OwnerId");
+
+ b.ToTable("public_shares", (string)null);
+ });
+
+ modelBuilder.Entity("OpenShock.Common.OpenShockDb.PublicShareShocker", b =>
+ {
+ b.Property("PublicShareId")
+ .HasColumnType("uuid")
+ .HasColumnName("public_share_id");
+
+ b.Property("ShockerId")
+ .HasColumnType("uuid")
+ .HasColumnName("shocker_id");
+
+ b.Property("AllowLiveControl")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("boolean")
+ .HasDefaultValue(false)
+ .HasColumnName("allow_livecontrol");
+
+ b.Property("AllowShock")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("boolean")
+ .HasDefaultValue(true)
+ .HasColumnName("allow_shock");
+
+ b.Property("AllowSound")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("boolean")
+ .HasDefaultValue(true)
+ .HasColumnName("allow_sound");
+
+ b.Property("AllowVibrate")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("boolean")
+ .HasDefaultValue(true)
+ .HasColumnName("allow_vibrate");
+
+ b.Property("Cooldown")
+ .HasColumnType("integer")
+ .HasColumnName("cooldown");
+
+ b.Property("IsPaused")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("boolean")
+ .HasDefaultValue(false)
+ .HasColumnName("is_paused");
+
+ b.Property("MaxDuration")
+ .HasColumnType("integer")
+ .HasColumnName("max_duration");
+
+ b.Property("MaxIntensity")
+ .HasColumnType("smallint")
+ .HasColumnName("max_intensity");
+
+ b.HasKey("PublicShareId", "ShockerId")
+ .HasName("public_share_shockers_pkey");
+
+ b.HasIndex("ShockerId");
+
+ b.ToTable("public_share_shockers", (string)null);
+ });
+
+ modelBuilder.Entity("OpenShock.Common.OpenShockDb.Shocker", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("CreatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at")
+ .HasDefaultValueSql("CURRENT_TIMESTAMP");
+
+ b.Property("DeviceId")
+ .HasColumnType("uuid")
+ .HasColumnName("device_id");
+
+ b.Property("IsPaused")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("boolean")
+ .HasDefaultValue(false)
+ .HasColumnName("is_paused");
+
+ b.Property("Model")
+ .HasColumnType("shocker_model_type")
+ .HasColumnName("model");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)")
+ .HasColumnName("name");
+
+ b.Property("RfId")
+ .HasColumnType("integer")
+ .HasColumnName("rf_id");
+
+ b.HasKey("Id")
+ .HasName("shockers_pkey");
+
+ b.HasIndex("DeviceId");
+
+ b.ToTable("shockers", (string)null);
+ });
+
+ modelBuilder.Entity("OpenShock.Common.OpenShockDb.ShockerControlLog", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("ControlledByUserId")
+ .HasColumnType("uuid")
+ .HasColumnName("controlled_by_user_id");
+
+ b.Property("CreatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at")
+ .HasDefaultValueSql("CURRENT_TIMESTAMP");
+
+ b.Property("CustomName")
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)")
+ .HasColumnName("custom_name");
+
+ b.Property("Duration")
+ .HasColumnType("bigint")
+ .HasColumnName("duration");
+
+ b.Property("Intensity")
+ .HasColumnType("smallint")
+ .HasColumnName("intensity");
+
+ b.Property("LiveControl")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("boolean")
+ .HasDefaultValue(false)
+ .HasColumnName("live_control");
+
+ b.Property("ShockerId")
+ .HasColumnType("uuid")
+ .HasColumnName("shocker_id");
+
+ b.Property("Type")
+ .HasColumnType("control_type")
+ .HasColumnName("type");
+
+ b.HasKey("Id")
+ .HasName("shocker_control_logs_pkey");
+
+ b.HasIndex("ControlledByUserId");
+
+ b.HasIndex("ShockerId");
+
+ b.ToTable("shocker_control_logs", (string)null);
+ });
+
+ modelBuilder.Entity("OpenShock.Common.OpenShockDb.ShockerShareCode", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("AllowLiveControl")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("boolean")
+ .HasDefaultValue(true)
+ .HasColumnName("allow_livecontrol");
+
+ b.Property("AllowShock")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("boolean")
+ .HasDefaultValue(true)
+ .HasColumnName("allow_shock");
+
+ b.Property("AllowSound")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("boolean")
+ .HasDefaultValue(true)
+ .HasColumnName("allow_sound");
+
+ b.Property("AllowVibrate")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("boolean")
+ .HasDefaultValue(true)
+ .HasColumnName("allow_vibrate");
+
+ b.Property("CreatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at")
+ .HasDefaultValueSql("CURRENT_TIMESTAMP");
+
+ b.Property("IsPaused")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("boolean")
+ .HasDefaultValue(false)
+ .HasColumnName("is_paused");
+
+ b.Property("MaxDuration")
+ .HasColumnType("integer")
+ .HasColumnName("max_duration");
+
+ b.Property("MaxIntensity")
+ .HasColumnType("smallint")
+ .HasColumnName("max_intensity");
+
+ b.Property("ShockerId")
+ .HasColumnType("uuid")
+ .HasColumnName("shocker_id");
+
+ b.HasKey("Id")
+ .HasName("shocker_share_codes_pkey");
+
+ b.HasIndex("ShockerId");
+
+ b.ToTable("shocker_share_codes", (string)null);
+ });
+
+ modelBuilder.Entity("OpenShock.Common.OpenShockDb.User", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("ActivatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("activated_at");
+
+ b.Property("CreatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at")
+ .HasDefaultValueSql("CURRENT_TIMESTAMP");
+
+ b.Property("Email")
+ .IsRequired()
+ .HasMaxLength(320)
+ .HasColumnType("character varying(320)")
+ .HasColumnName("email");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("character varying(32)")
+ .HasColumnName("name")
+ .UseCollation("ndcoll");
+
+ b.Property("PasswordHash")
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)")
+ .HasColumnName("password_hash")
+ .UseCollation("C");
+
+ b.PrimitiveCollection>("Roles")
+ .IsRequired()
+ .HasColumnType("role_type[]")
+ .HasColumnName("roles");
+
+ b.HasKey("Id")
+ .HasName("users_pkey");
+
+ b.HasIndex("Email")
+ .IsUnique();
+
+ b.HasIndex("Name")
+ .IsUnique();
+
+ NpgsqlIndexBuilderExtensions.UseCollation(b.HasIndex("Name"), new[] { "ndcoll" });
+
+ b.ToTable("users", (string)null);
+ });
+
+ modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserActivationRequest", b =>
+ {
+ b.Property("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.Property("CreatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at")
+ .HasDefaultValueSql("CURRENT_TIMESTAMP");
+
+ b.Property("EmailSendAttempts")
+ .HasColumnType("integer")
+ .HasColumnName("email_send_attempts");
+
+ b.Property("TokenHash")
+ .IsRequired()
+ .HasMaxLength(128)
+ .HasColumnType("character varying(128)")
+ .HasColumnName("token_hash")
+ .UseCollation("C");
+
+ b.HasKey("UserId")
+ .HasName("user_activation_requests_pkey");
+
+ b.HasIndex("TokenHash")
+ .IsUnique();
+
+ b.ToTable("user_activation_requests", (string)null);
+ });
+
+ modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserDeactivation", b =>
+ {
+ b.Property("DeactivatedUserId")
+ .HasColumnType("uuid")
+ .HasColumnName("deactivated_user_id");
+
+ b.Property("CreatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at")
+ .HasDefaultValueSql("CURRENT_TIMESTAMP");
+
+ b.Property("DeactivatedByUserId")
+ .HasColumnType("uuid")
+ .HasColumnName("deactivated_by_user_id");
+
+ b.Property("DeleteLater")
+ .HasColumnType("boolean")
+ .HasColumnName("delete_later");
+
+ b.Property("UserModerationId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_moderation_id");
+
+ b.HasKey("DeactivatedUserId")
+ .HasName("user_deactivations_pkey");
+
+ b.HasIndex("DeactivatedByUserId");
+
+ b.ToTable("user_deactivations", (string)null);
+ });
+
+ modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserEmailChange", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("CreatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at")
+ .HasDefaultValueSql("CURRENT_TIMESTAMP");
+
+ b.Property("NewEmail")
+ .IsRequired()
+ .HasMaxLength(320)
+ .HasColumnType("character varying(320)")
+ .HasColumnName("email_new");
+
+ b.Property("OldEmail")
+ .IsRequired()
+ .HasMaxLength(320)
+ .HasColumnType("character varying(320)")
+ .HasColumnName("email_old");
+
+ b.Property("TokenHash")
+ .IsRequired()
+ .HasMaxLength(128)
+ .HasColumnType("character varying(128)")
+ .HasColumnName("token_hash")
+ .UseCollation("C");
+
+ b.Property("UsedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("used_at");
+
+ b.Property("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("Id")
+ .HasName("user_email_changes_pkey");
+
+ b.HasIndex("CreatedAt");
+
+ b.HasIndex("UsedAt");
+
+ b.HasIndex("UserId");
+
+ b.ToTable("user_email_changes", (string)null);
+ });
+
+ modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserNameBlacklist", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("CreatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at")
+ .HasDefaultValueSql("CURRENT_TIMESTAMP");
+
+ b.Property("MatchType")
+ .HasColumnType("match_type_enum")
+ .HasColumnName("match_type");
+
+ b.Property("Value")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("character varying(32)")
+ .HasColumnName("value")
+ .UseCollation("ndcoll");
+
+ b.HasKey("Id")
+ .HasName("user_name_blacklist_pkey");
+
+ b.HasIndex("Value")
+ .IsUnique();
+
+ NpgsqlIndexBuilderExtensions.UseCollation(b.HasIndex("Value"), new[] { "ndcoll" });
+
+ b.ToTable("user_name_blacklist", (string)null);
+ });
+
+ modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserNameChange", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasColumnName("id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityAlwaysColumn(b.Property("Id"));
+
+ b.Property("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.Property("CreatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at")
+ .HasDefaultValueSql("CURRENT_TIMESTAMP");
+
+ b.Property("OldName")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("character varying(32)")
+ .HasColumnName("old_name");
+
+ b.HasKey("Id", "UserId")
+ .HasName("user_name_changes_pkey");
+
+ b.HasIndex("CreatedAt");
+
+ b.HasIndex("OldName");
+
+ b.HasIndex("UserId");
+
+ b.ToTable("user_name_changes", (string)null);
+ });
+
+ modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserOAuthConnection", b =>
+ {
+ b.Property("ProviderKey")
+ .HasColumnType("text")
+ .HasColumnName("provider_key")
+ .UseCollation("C");
+
+ b.Property("ExternalId")
+ .HasColumnType("text")
+ .HasColumnName("external_id");
+
+ b.Property("CreatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at")
+ .HasDefaultValueSql("CURRENT_TIMESTAMP");
+
+ b.Property("DisplayName")
+ .HasColumnType("text")
+ .HasColumnName("display_name");
+
+ b.Property("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("ProviderKey", "ExternalId")
+ .HasName("user_oauth_connections_pkey");
+
+ b.HasIndex("UserId");
+
+ b.ToTable("user_oauth_connections", (string)null);
+ });
+
+ modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserPasswordReset", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("CreatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at")
+ .HasDefaultValueSql("CURRENT_TIMESTAMP");
+
+ b.Property("TokenHash")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)")
+ .HasColumnName("token_hash")
+ .UseCollation("C");
+
+ b.Property("UsedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("used_at");
+
+ b.Property("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("Id")
+ .HasName("user_password_resets_pkey");
+
+ b.HasIndex("UserId");
+
+ b.ToTable("user_password_resets", (string)null);
+ });
+
+ modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserShare", b =>
+ {
+ b.Property("SharedWithUserId")
+ .HasColumnType("uuid")
+ .HasColumnName("shared_with_user_id");
+
+ b.Property("ShockerId")
+ .HasColumnType("uuid")
+ .HasColumnName("shocker_id");
+
+ b.Property("AllowLiveControl")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("boolean")
+ .HasDefaultValue(true)
+ .HasColumnName("allow_livecontrol");
+
+ b.Property("AllowShock")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("boolean")
+ .HasDefaultValue(true)
+ .HasColumnName("allow_shock");
+
+ b.Property("AllowSound")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("boolean")
+ .HasDefaultValue(true)
+ .HasColumnName("allow_sound");
+
+ b.Property("AllowVibrate")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("boolean")
+ .HasDefaultValue(true)
+ .HasColumnName("allow_vibrate");
+
+ b.Property("CreatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at")
+ .HasDefaultValueSql("CURRENT_TIMESTAMP");
+
+ b.Property("IsPaused")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("boolean")
+ .HasDefaultValue(false)
+ .HasColumnName("is_paused");
+
+ b.Property("MaxDuration")
+ .HasColumnType("integer")
+ .HasColumnName("max_duration");
+
+ b.Property("MaxIntensity")
+ .HasColumnType("smallint")
+ .HasColumnName("max_intensity");
+
+ b.HasKey("SharedWithUserId", "ShockerId")
+ .HasName("user_shares_pkey");
+
+ b.HasIndex("SharedWithUserId");
+
+ b.HasIndex("ShockerId");
+
+ b.ToTable("user_shares", (string)null);
+ });
+
+ modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserShareInvite", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("CreatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at")
+ .HasDefaultValueSql("CURRENT_TIMESTAMP");
+
+ b.Property("OwnerId")
+ .HasColumnType("uuid")
+ .HasColumnName("owner_id");
+
+ b.Property("RecipientUserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("Id")
+ .HasName("user_share_invites_pkey");
+
+ b.HasIndex("OwnerId");
+
+ b.HasIndex("RecipientUserId");
+
+ b.ToTable("user_share_invites", (string)null);
+ });
+
+ modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserShareInviteShocker", b =>
+ {
+ b.Property("InviteId")
+ .HasColumnType("uuid")
+ .HasColumnName("invite_id");
+
+ b.Property("ShockerId")
+ .HasColumnType("uuid")
+ .HasColumnName("shocker_id");
+
+ b.Property("AllowLiveControl")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("boolean")
+ .HasDefaultValue(true)
+ .HasColumnName("allow_livecontrol");
+
+ b.Property("AllowShock")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("boolean")
+ .HasDefaultValue(true)
+ .HasColumnName("allow_shock");
+
+ b.Property("AllowSound")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("boolean")
+ .HasDefaultValue(true)
+ .HasColumnName("allow_sound");
+
+ b.Property("AllowVibrate")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("boolean")
+ .HasDefaultValue(true)
+ .HasColumnName("allow_vibrate");
+
+ b.Property("IsPaused")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("boolean")
+ .HasDefaultValue(false)
+ .HasColumnName("is_paused");
+
+ b.Property("MaxDuration")
+ .HasColumnType("integer")
+ .HasColumnName("max_duration");
+
+ b.Property("MaxIntensity")
+ .HasColumnType("smallint")
+ .HasColumnName("max_intensity");
+
+ b.HasKey("InviteId", "ShockerId")
+ .HasName("user_share_invite_shockers_pkey");
+
+ b.HasIndex("ShockerId");
+
+ b.ToTable("user_share_invite_shockers", (string)null);
+ });
+
+ modelBuilder.Entity("OpenShock.Common.OpenShockDb.ApiToken", b =>
+ {
+ b.HasOne("OpenShock.Common.OpenShockDb.User", "User")
+ .WithMany("ApiTokens")
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_api_tokens_user_id");
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("OpenShock.Common.OpenShockDb.ApiTokenReport", b =>
+ {
+ b.HasOne("OpenShock.Common.OpenShockDb.User", "ReportedByUser")
+ .WithMany("ReportedApiTokens")
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_api_token_reports_reported_by_user_id");
+
+ b.Navigation("ReportedByUser");
+ });
+
+ modelBuilder.Entity("OpenShock.Common.OpenShockDb.Device", b =>
+ {
+ b.HasOne("OpenShock.Common.OpenShockDb.User", "Owner")
+ .WithMany("Devices")
+ .HasForeignKey("OwnerId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_devices_owner_id");
+
+ b.Navigation("Owner");
+ });
+
+ modelBuilder.Entity("OpenShock.Common.OpenShockDb.DeviceOtaUpdate", b =>
+ {
+ b.HasOne("OpenShock.Common.OpenShockDb.Device", "Device")
+ .WithMany("OtaUpdates")
+ .HasForeignKey("DeviceId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_device_ota_updates_device_id");
+
+ b.Navigation("Device");
+ });
+
+ modelBuilder.Entity("OpenShock.Common.OpenShockDb.PublicShare", b =>
+ {
+ b.HasOne("OpenShock.Common.OpenShockDb.User", "Owner")
+ .WithMany("OwnedPublicShares")
+ .HasForeignKey("OwnerId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_public_shares_owner_id");
+
+ b.Navigation("Owner");
+ });
+
+ modelBuilder.Entity("OpenShock.Common.OpenShockDb.PublicShareShocker", b =>
+ {
+ b.HasOne("OpenShock.Common.OpenShockDb.PublicShare", "PublicShare")
+ .WithMany("ShockerMappings")
+ .HasForeignKey("PublicShareId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_public_share_shockers_public_share_id");
+
+ b.HasOne("OpenShock.Common.OpenShockDb.Shocker", "Shocker")
+ .WithMany("PublicShareMappings")
+ .HasForeignKey("ShockerId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_public_share_shockers_shocker_id");
+
+ b.Navigation("PublicShare");
+
+ b.Navigation("Shocker");
+ });
+
+ modelBuilder.Entity("OpenShock.Common.OpenShockDb.Shocker", b =>
+ {
+ b.HasOne("OpenShock.Common.OpenShockDb.Device", "Device")
+ .WithMany("Shockers")
+ .HasForeignKey("DeviceId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_shockers_device_id");
+
+ b.Navigation("Device");
+ });
+
+ modelBuilder.Entity("OpenShock.Common.OpenShockDb.ShockerControlLog", b =>
+ {
+ b.HasOne("OpenShock.Common.OpenShockDb.User", "ControlledByUser")
+ .WithMany("ShockerControlLogs")
+ .HasForeignKey("ControlledByUserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .HasConstraintName("fk_shocker_control_logs_controlled_by_user_id");
+
+ b.HasOne("OpenShock.Common.OpenShockDb.Shocker", "Shocker")
+ .WithMany("ShockerControlLogs")
+ .HasForeignKey("ShockerId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_shocker_control_logs_shocker_id");
+
+ b.Navigation("ControlledByUser");
+
+ b.Navigation("Shocker");
+ });
+
+ modelBuilder.Entity("OpenShock.Common.OpenShockDb.ShockerShareCode", b =>
+ {
+ b.HasOne("OpenShock.Common.OpenShockDb.Shocker", "Shocker")
+ .WithMany("ShockerShareCodes")
+ .HasForeignKey("ShockerId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_shocker_share_codes_shocker_id");
+
+ b.Navigation("Shocker");
+ });
+
+ modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserActivationRequest", b =>
+ {
+ b.HasOne("OpenShock.Common.OpenShockDb.User", "User")
+ .WithOne("UserActivationRequest")
+ .HasForeignKey("OpenShock.Common.OpenShockDb.UserActivationRequest", "UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_user_activation_requests_user_id");
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserDeactivation", b =>
+ {
+ b.HasOne("OpenShock.Common.OpenShockDb.User", "DeactivatedByUser")
+ .WithMany()
+ .HasForeignKey("DeactivatedByUserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_user_deactivations_deactivated_by_user_id");
+
+ b.HasOne("OpenShock.Common.OpenShockDb.User", "DeactivatedUser")
+ .WithOne("UserDeactivation")
+ .HasForeignKey("OpenShock.Common.OpenShockDb.UserDeactivation", "DeactivatedUserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_user_deactivations_deactivated_user_id");
+
+ b.Navigation("DeactivatedByUser");
+
+ b.Navigation("DeactivatedUser");
+ });
+
+ modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserEmailChange", b =>
+ {
+ b.HasOne("OpenShock.Common.OpenShockDb.User", "User")
+ .WithMany("EmailChanges")
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_user_email_changes_user_id");
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserNameChange", b =>
+ {
+ b.HasOne("OpenShock.Common.OpenShockDb.User", "User")
+ .WithMany("NameChanges")
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_user_name_changes_user_id");
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserOAuthConnection", b =>
+ {
+ b.HasOne("OpenShock.Common.OpenShockDb.User", "User")
+ .WithMany("OAuthConnections")
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_user_oauth_connections_user_id");
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserPasswordReset", b =>
+ {
+ b.HasOne("OpenShock.Common.OpenShockDb.User", "User")
+ .WithMany("PasswordResets")
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_user_password_resets_user_id");
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserShare", b =>
+ {
+ b.HasOne("OpenShock.Common.OpenShockDb.User", "SharedWithUser")
+ .WithMany("IncomingUserShares")
+ .HasForeignKey("SharedWithUserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_user_shares_shared_with_user_id");
+
+ b.HasOne("OpenShock.Common.OpenShockDb.Shocker", "Shocker")
+ .WithMany("UserShares")
+ .HasForeignKey("ShockerId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_user_shares_shocker_id");
+
+ b.Navigation("SharedWithUser");
+
+ b.Navigation("Shocker");
+ });
+
+ modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserShareInvite", b =>
+ {
+ b.HasOne("OpenShock.Common.OpenShockDb.User", "Owner")
+ .WithMany("OutgoingUserShareInvites")
+ .HasForeignKey("OwnerId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_user_share_invites_owner_id");
+
+ b.HasOne("OpenShock.Common.OpenShockDb.User", "RecipientUser")
+ .WithMany("IncomingUserShareInvites")
+ .HasForeignKey("RecipientUserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .HasConstraintName("fk_user_share_invites_recipient_user_id");
+
+ b.Navigation("Owner");
+
+ b.Navigation("RecipientUser");
+ });
+
+ modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserShareInviteShocker", b =>
+ {
+ b.HasOne("OpenShock.Common.OpenShockDb.UserShareInvite", "Invite")
+ .WithMany("ShockerMappings")
+ .HasForeignKey("InviteId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_user_share_invite_shockers_invite_id");
+
+ b.HasOne("OpenShock.Common.OpenShockDb.Shocker", "Shocker")
+ .WithMany("UserShareInviteShockerMappings")
+ .HasForeignKey("ShockerId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_user_share_invite_shockers_shocker_id");
+
+ b.Navigation("Invite");
+
+ b.Navigation("Shocker");
+ });
+
+ modelBuilder.Entity("OpenShock.Common.OpenShockDb.Device", b =>
+ {
+ b.Navigation("OtaUpdates");
+
+ b.Navigation("Shockers");
+ });
+
+ modelBuilder.Entity("OpenShock.Common.OpenShockDb.PublicShare", b =>
+ {
+ b.Navigation("ShockerMappings");
+ });
+
+ modelBuilder.Entity("OpenShock.Common.OpenShockDb.Shocker", b =>
+ {
+ b.Navigation("PublicShareMappings");
+
+ b.Navigation("ShockerControlLogs");
+
+ b.Navigation("ShockerShareCodes");
+
+ b.Navigation("UserShareInviteShockerMappings");
+
+ b.Navigation("UserShares");
+ });
+
+ modelBuilder.Entity("OpenShock.Common.OpenShockDb.User", b =>
+ {
+ b.Navigation("ApiTokens");
+
+ b.Navigation("Devices");
+
+ b.Navigation("EmailChanges");
+
+ b.Navigation("IncomingUserShareInvites");
+
+ b.Navigation("IncomingUserShares");
+
+ b.Navigation("NameChanges");
+
+ b.Navigation("OAuthConnections");
+
+ b.Navigation("OutgoingUserShareInvites");
+
+ b.Navigation("OwnedPublicShares");
+
+ b.Navigation("PasswordResets");
+
+ b.Navigation("ReportedApiTokens");
+
+ b.Navigation("ShockerControlLogs");
+
+ b.Navigation("UserActivationRequest");
+
+ b.Navigation("UserDeactivation");
+ });
+
+ modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserShareInvite", b =>
+ {
+ b.Navigation("ShockerMappings");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/Common/Migrations/20250903235304_AddOAuthSupport.cs b/Common/Migrations/20250903235304_AddOAuthSupport.cs
new file mode 100644
index 00000000..632165bc
--- /dev/null
+++ b/Common/Migrations/20250903235304_AddOAuthSupport.cs
@@ -0,0 +1,92 @@
+using System;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+
+#nullable disable
+
+namespace OpenShock.Common.Migrations
+{
+ ///
+ public partial class AddOAuthSupport : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.AlterColumn(
+ name: "password_hash",
+ table: "users",
+ type: "character varying(100)",
+ maxLength: 100,
+ nullable: true,
+ collation: "C",
+ oldClrType: typeof(string),
+ oldType: "character varying(100)",
+ oldMaxLength: 100,
+ oldCollation: "C");
+
+ migrationBuilder.CreateTable(
+ name: "DataProtectionKeys",
+ columns: table => new
+ {
+ Id = table.Column(type: "integer", nullable: false)
+ .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
+ FriendlyName = table.Column(type: "text", nullable: true),
+ Xml = table.Column(type: "text", nullable: true)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_DataProtectionKeys", x => x.Id);
+ });
+
+ migrationBuilder.CreateTable(
+ name: "user_oauth_connections",
+ columns: table => new
+ {
+ provider_key = table.Column(type: "text", nullable: false, collation: "C"),
+ external_id = table.Column(type: "text", nullable: false),
+ user_id = table.Column(type: "uuid", nullable: false),
+ display_name = table.Column(type: "text", nullable: true),
+ created_at = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP")
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("user_oauth_connections_pkey", x => new { x.provider_key, x.external_id });
+ table.ForeignKey(
+ name: "fk_user_oauth_connections_user_id",
+ column: x => x.user_id,
+ principalTable: "users",
+ principalColumn: "id",
+ onDelete: ReferentialAction.Cascade);
+ });
+
+ migrationBuilder.CreateIndex(
+ name: "IX_user_oauth_connections_user_id",
+ table: "user_oauth_connections",
+ column: "user_id");
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropTable(
+ name: "DataProtectionKeys");
+
+ migrationBuilder.DropTable(
+ name: "user_oauth_connections");
+
+ migrationBuilder.AlterColumn(
+ name: "password_hash",
+ table: "users",
+ type: "character varying(100)",
+ maxLength: 100,
+ nullable: false,
+ defaultValue: "",
+ collation: "C",
+ oldClrType: typeof(string),
+ oldType: "character varying(100)",
+ oldMaxLength: 100,
+ oldNullable: true,
+ oldCollation: "C");
+ }
+ }
+}
diff --git a/Common/Migrations/OpenShockContextModelSnapshot.cs b/Common/Migrations/OpenShockContextModelSnapshot.cs
index c91a9061..645c590e 100644
--- a/Common/Migrations/OpenShockContextModelSnapshot.cs
+++ b/Common/Migrations/OpenShockContextModelSnapshot.cs
@@ -21,7 +21,7 @@ protected override void BuildModel(ModelBuilder modelBuilder)
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("Npgsql:CollationDefinition:public.ndcoll", "und-u-ks-level2,und-u-ks-level2,icu,False")
- .HasAnnotation("ProductVersion", "9.0.7")
+ .HasAnnotation("ProductVersion", "9.0.8")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "configuration_value_type", new[] { "string", "bool", "int", "float", "json" });
@@ -34,6 +34,25 @@ protected override void BuildModel(ModelBuilder modelBuilder)
NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "shocker_model_type", new[] { "caiXianlin", "petTrainer", "petrainer998DR" });
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+ modelBuilder.Entity("Microsoft.AspNetCore.DataProtection.EntityFrameworkCore.DataProtectionKey", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("FriendlyName")
+ .HasColumnType("text");
+
+ b.Property("Xml")
+ .HasColumnType("text");
+
+ b.HasKey("Id");
+
+ b.ToTable("DataProtectionKeys");
+ });
+
modelBuilder.Entity("OpenShock.Common.OpenShockDb.AdminUsersView", b =>
{
b.Property("ActivatedAt")
@@ -680,7 +699,6 @@ protected override void BuildModel(ModelBuilder modelBuilder)
.UseCollation("ndcoll");
b.Property("PasswordHash")
- .IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("password_hash")
@@ -891,6 +909,39 @@ protected override void BuildModel(ModelBuilder modelBuilder)
b.ToTable("user_name_changes", (string)null);
});
+ modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserOAuthConnection", b =>
+ {
+ b.Property("ProviderKey")
+ .HasColumnType("text")
+ .HasColumnName("provider_key")
+ .UseCollation("C");
+
+ b.Property("ExternalId")
+ .HasColumnType("text")
+ .HasColumnName("external_id");
+
+ b.Property