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 d25c2f43..1bd71e4a 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(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..23d7afec
--- /dev/null
+++ b/API/Controller/Account/Authenticated/OAuthConnectionAdd.cs
@@ -0,0 +1,43 @@
+using Microsoft.AspNetCore.Authentication;
+using Microsoft.AspNetCore.Mvc;
+using OpenShock.API.Extensions;
+using OpenShock.Common.Constants;
+using OpenShock.Common.Errors;
+using OpenShock.Common.Problems;
+using System.Net.Mime;
+
+namespace OpenShock.API.Controller.Account.Authenticated;
+
+public sealed partial class AuthenticatedAccountController
+{
+ ///
+ /// 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);
+
+ // Kick off provider challenge in "link" mode.
+ // Redirect URI is our handoff endpoint which decides next UI step.
+ var props = new AuthenticationProperties {
+ RedirectUri = $"/1/oauth/{provider}/handoff",
+ Items = { { "flow", AuthConstants.OAuthLinkFlow } }
+ };
+
+ return Challenge(props, provider);
+ }
+}
\ 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..18100bee
--- /dev/null
+++ b/API/Controller/Account/Authenticated/OAuthConnectionRemove.cs
@@ -0,0 +1,27 @@
+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)
+ {
+ var deleted = await connectionService.TryRemoveConnectionAsync(CurrentUser.Id, provider);
+
+ 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..3cdc1b65
--- /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)
+ {
+ var connections = await connectionService.GetConnectionsAsync(CurrentUser.Id);
+
+ return connections
+ .Select(c => new OAuthConnectionResponse
+ {
+ ProviderKey = c.ProviderKey,
+ ExternalId = c.ExternalId,
+ DisplayName = c.DisplayName,
+ LinkedAt = c.CreatedAt
+ })
+ .ToArray();
+ }
+}
\ No newline at end of file
diff --git a/API/Controller/Account/Login.cs b/API/Controller/Account/Login.cs
index f8027a12..f6238070 100644
--- a/API/Controller/Account/Login.cs
+++ b/API/Controller/Account/Login.cs
@@ -46,8 +46,9 @@ public async Task Login(
HttpContext.SetSessionKeyCookie(ok.Token, "." + cookieDomainToUse);
return LegacyEmptyOk("Successfully logged in");
},
- notActivated => Problem(AccountError.AccountNotActivated),
deactivated => Problem(AccountError.AccountDeactivated),
+ oauthOnly => Problem(AccountError.AccountOAuthOnly),
+ notActivated => Problem(AccountError.AccountNotActivated),
notFound => Problem(LoginError.InvalidCredentials)
);
}
diff --git a/API/Controller/Account/LoginV2.cs b/API/Controller/Account/LoginV2.cs
index b426cf61..5a6edecf 100644
--- a/API/Controller/Account/LoginV2.cs
+++ b/API/Controller/Account/LoginV2.cs
@@ -62,8 +62,9 @@ public async Task LoginV2(
HttpContext.SetSessionKeyCookie(ok.Token, "." + cookieDomainToUse);
return Ok(LoginV2OkResponse.FromUser(ok.User));
},
- notActivated => Problem(AccountError.AccountNotActivated),
deactivated => Problem(AccountError.AccountDeactivated),
+ oauthOnly => Problem(AccountError.AccountOAuthOnly),
+ notActivated => Problem(AccountError.AccountNotActivated),
notFound => Problem(LoginError.InvalidCredentials)
);
}
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 6e5ae089..f5dd0b26 100644
--- a/API/Controller/Account/SignupV2.cs
+++ b/API/Controller/Account/SignupV2.cs
@@ -45,7 +45,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..f30d2d57
--- /dev/null
+++ b/API/Controller/OAuth/Authorize.cs
@@ -0,0 +1,42 @@
+using Microsoft.AspNetCore.Authentication;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.RateLimiting;
+using OpenShock.API.Extensions;
+using OpenShock.Common.Constants;
+using OpenShock.Common.Errors;
+using OpenShock.Common.Problems;
+using System.Net.Mime;
+
+namespace OpenShock.API.Controller.OAuth;
+
+public sealed partial class OAuthController
+{
+ ///
+ /// 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);
+
+ // Kick off provider challenge in "login-or-create" mode.
+ var props = new AuthenticationProperties
+ {
+ RedirectUri = $"/1/oauth/{provider}/handoff",
+ Items = { { "flow", AuthConstants.OAuthLoginOrCreateFlow } }
+ };
+
+ return Challenge(props, provider);
+ }
+}
\ No newline at end of file
diff --git a/API/Controller/OAuth/Finalize.cs b/API/Controller/OAuth/Finalize.cs
new file mode 100644
index 00000000..27b34dc4
--- /dev/null
+++ b/API/Controller/OAuth/Finalize.cs
@@ -0,0 +1,216 @@
+using Microsoft.AspNetCore.Authentication;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.RateLimiting;
+using OpenShock.API.Extensions;
+using OpenShock.API.Models.Requests;
+using OpenShock.API.Models.Response;
+using OpenShock.API.Services.Account;
+using OpenShock.API.Services.OAuthConnection;
+using OpenShock.Common.Authentication;
+using OpenShock.Common.Authentication.Services;
+using OpenShock.Common.Constants;
+using OpenShock.Common.Errors;
+using OpenShock.Common.OpenShockDb;
+using OpenShock.Common.Problems;
+using OpenShock.Common.Utils;
+using System.Net.Mime;
+using System.Security.Claims;
+
+namespace OpenShock.API.Controller.OAuth;
+
+public sealed partial class OAuthController
+{
+ ///
+ /// Finalize an OAuth flow by either creating a new local account or linking to the current account.
+ ///
+ ///
+ /// Authenticates via the temporary OAuth flow cookie (set during the provider callback).
+ /// - create: creates a local account, then links the external identity.
+ /// - link: requires a logged-in local user; links the external identity to that user.
+ /// No access/refresh tokens are returned.
+ ///
+ /// Provider key (e.g. discord).
+ /// Finalize request.
+ ///
+ ///
+ /// Finalization succeeded.
+ /// Flow not found, bad action, username invalid, or provider mismatch.
+ /// Link requested but user not authenticated.
+ /// External already linked to another account, or duplicate link attempt.
+ [EnableRateLimiting("auth")]
+ [HttpPost("{provider}/finalize")]
+ [ProducesResponseType(typeof(OAuthFinalizeResponse), StatusCodes.Status200OK, MediaTypeNames.Application.Json)]
+ [ProducesResponseType(typeof(OpenShockProblem), StatusCodes.Status400BadRequest, MediaTypeNames.Application.Json)]
+ [ProducesResponseType(typeof(OpenShockProblem), StatusCodes.Status401Unauthorized, MediaTypeNames.Application.Json)]
+ [ProducesResponseType(typeof(OpenShockProblem), StatusCodes.Status409Conflict, MediaTypeNames.Application.Json)]
+ public async Task OAuthFinalize(
+ [FromRoute] string provider,
+ [FromBody] OAuthFinalizeRequest body,
+ [FromServices] IAccountService accountService,
+ [FromServices] IOAuthConnectionService connectionService)
+ {
+ if (!await _schemeProvider.IsSupportedOAuthScheme(provider))
+ return Problem(OAuthError.UnsupportedProvider);
+
+ var action = body.Action?.Trim().ToLowerInvariant();
+ if (action is not (AuthConstants.OAuthLoginOrCreateFlow or AuthConstants.OAuthLinkFlow))
+ return Problem(OAuthError.UnsupportedFlow);
+
+ // Authenticate via the short-lived OAuth flow cookie (temp scheme)
+ var auth = await HttpContext.AuthenticateAsync(OpenShockAuthSchemes.OAuthFlowScheme);
+ if (!auth.Succeeded || auth.Principal is null)
+ return Problem(OAuthError.FlowNotFound);
+
+ // Flow must belong to the same provider we’re finalizing
+ var providerClaim = auth.Principal.FindFirst("provider")?.Value;
+ if (!string.Equals(providerClaim, provider, StringComparison.OrdinalIgnoreCase))
+ {
+ await HttpContext.SignOutAsync(OpenShockAuthSchemes.OAuthFlowScheme);
+ return Problem(OAuthError.ProviderMismatch);
+ }
+
+ // External identity basics from claims (added by your handler)
+ var externalId = auth.Principal.FindFirst(ClaimTypes.NameIdentifier)?.Value;
+ if (string.IsNullOrEmpty(externalId))
+ {
+ await HttpContext.SignOutAsync(OpenShockAuthSchemes.OAuthFlowScheme);
+ return Problem(OAuthError.FlowMissingData);
+ }
+
+ var email = auth.Principal.FindFirst(ClaimTypes.Email)?.Value;
+ var displayName = auth.Principal.FindFirst(ClaimTypes.Name)?.Value;
+
+ // If the external is already linked, don’t allow relinking in either flow.
+ var existing = await connectionService.GetByProviderExternalIdAsync(provider, externalId);
+
+ if (action == AuthConstants.OAuthLinkFlow)
+ {
+ // Linking requires an authenticated session
+ var userRef = HttpContext.RequestServices.GetRequiredService();
+ if (userRef.AuthReference is null || !userRef.AuthReference.Value.IsT0)
+ {
+ // Not a logged-in session (could be API token or anonymous)
+ return Problem(OAuthError.NotAuthenticatedForLink);
+ }
+
+ var currentUser = HttpContext.RequestServices
+ .GetRequiredService>()
+ .CurrentClient;
+
+ if (existing is not null)
+ {
+ // Already linked to someone, block.
+ await HttpContext.SignOutAsync(OpenShockAuthSchemes.OAuthFlowScheme);
+ return Problem(OAuthError.ExternalAlreadyLinked);
+ }
+
+ var ok = await connectionService.TryAddConnectionAsync(
+ userId: currentUser.Id,
+ provider: provider,
+ providerAccountId: externalId,
+ providerAccountName: displayName ?? email);
+
+ await HttpContext.SignOutAsync(OpenShockAuthSchemes.OAuthFlowScheme);
+
+ if (!ok) return Problem(OAuthError.ExternalAlreadyLinked);
+
+ return Ok(new OAuthFinalizeResponse
+ {
+ Provider = provider,
+ ExternalId = externalId
+ });
+ }
+
+ if (action is not AuthConstants.OAuthLoginOrCreateFlow)
+ {
+ await HttpContext.SignOutAsync(OpenShockAuthSchemes.OAuthFlowScheme);
+ return Problem(OAuthError.UnsupportedFlow);
+ }
+
+ if (existing is not null)
+ {
+ // External already mapped; treat as conflict (or you could return 200 if you consider this a no-op login).
+ await HttpContext.SignOutAsync(OpenShockAuthSchemes.OAuthFlowScheme);
+ return Problem(OAuthError.ConnectionAlreadyExists);
+ }
+
+ // We must create a local account. Your AccountService requires a password, so:
+ var desiredUsername = body.Username?.Trim();
+ if (string.IsNullOrEmpty(desiredUsername))
+ {
+ // Generate a reasonable username from displayName/email/externalId
+ desiredUsername = GenerateUsername(displayName, email, externalId, provider);
+ }
+
+ // Ensure username is available; if not, try a few suffixes
+ desiredUsername = await EnsureAvailableUsernameAsync(desiredUsername, accountService);
+
+ var password = string.IsNullOrEmpty(body.Password)
+ ? CryptoUtils.RandomString(32) // strong random (since OAuth-only users won't use it)
+ : body.Password;
+
+ var created = await accountService.CreateOAuthOnlyAccountAsync(
+ email: email!,
+ username: body.Username!,
+ provider: provider,
+ providerAccountId: externalId,
+ providerAccountName: displayName ?? email
+ );
+
+
+ if (created.IsT1)
+ {
+ return Problem(SignupError.UsernameOrEmailExists);
+ }
+
+ await HttpContext.SignOutAsync(OpenShockAuthSchemes.OAuthFlowScheme);
+
+ var newUser = created.AsT0.Value;
+
+ return Ok(new OAuthFinalizeResponse
+ {
+ Provider = provider,
+ ExternalId = externalId,
+ Username = newUser.Name
+ });
+
+ // ------- local helpers --------
+
+ static string GenerateUsername(string? name, string? mail, string externalId, string providerKey)
+ {
+ if (!string.IsNullOrWhiteSpace(name))
+ return Slugify(name);
+
+ if (!string.IsNullOrWhiteSpace(mail))
+ {
+ var at = mail.IndexOf('@');
+ if (at > 0) return Slugify(mail[..at]);
+ }
+
+ return $"{providerKey}_{externalId}".ToLowerInvariant();
+ }
+
+ static string Slugify(string s)
+ {
+ var slug = new string(s.Trim()
+ .ToLowerInvariant()
+ .Select(ch => char.IsLetterOrDigit(ch) ? ch : '_')
+ .ToArray());
+ slug = System.Text.RegularExpressions.Regex.Replace(slug, "_{2,}", "_").Trim('_');
+ return string.IsNullOrEmpty(slug) ? "user" : slug;
+ }
+
+ static async Task EnsureAvailableUsernameAsync(string baseName, IAccountService account)
+ {
+ var candidate = baseName;
+ for (var i = 0; i < 10; i++)
+ {
+ var check = await account.CheckUsernameAvailabilityAsync(candidate);
+ if (check.IsT0) return candidate; // Success
+ candidate = $"{baseName}_{CryptoUtils.RandomString(4).ToLowerInvariant()}";
+ }
+ // last resort: include a timestamp suffix
+ return $"{baseName}_{DateTimeOffset.UtcNow.ToUnixTimeSeconds()}";
+ }
+ }
+}
diff --git a/API/Controller/OAuth/GetData.cs b/API/Controller/OAuth/GetData.cs
new file mode 100644
index 00000000..475cd42a
--- /dev/null
+++ b/API/Controller/OAuth/GetData.cs
@@ -0,0 +1,67 @@
+using Microsoft.AspNetCore.Authentication;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.RateLimiting;
+using OpenShock.API.Extensions;
+using OpenShock.API.Models.Response;
+using OpenShock.Common.Authentication;
+using OpenShock.Common.Errors;
+using OpenShock.Common.Problems;
+using System.Net.Mime;
+using System.Security.Claims;
+
+namespace OpenShock.API.Controller.OAuth;
+
+public sealed partial class OAuthController
+{
+ ///
+ /// Retrieve short-lived OAuth handoff information for the current flow.
+ ///
+ ///
+ /// Returns identity details from the external provider (e.g., email, display name) along with the flow expiry.
+ /// This endpoint is authenticated via the temporary OAuth flow cookie and is only accessible to the user who initiated the flow.
+ ///
+ /// Provider key (e.g. discord).
+ /// Handoff data returned.
+ /// Flow not found or provider mismatch.
+ [ResponseCache(NoStore = true)]
+ [EnableRateLimiting("auth")]
+ [HttpGet("{provider}/data")]
+ [ProducesResponseType(StatusCodes.Status200OK, MediaTypeNames.Application.Json)]
+ [ProducesResponseType(StatusCodes.Status400BadRequest, MediaTypeNames.Application.Json)]
+ public async Task OAuthGetData([FromRoute] string provider)
+ {
+ if (!await _schemeProvider.IsSupportedOAuthScheme(provider))
+ return Problem(OAuthError.UnsupportedProvider);
+
+ // Temp external principal (set by OAuth handler with SignInScheme=OAuthFlowScheme, SaveTokens=true)
+ var auth = await HttpContext.AuthenticateAsync(OpenShockAuthSchemes.OAuthFlowScheme);
+ if (!auth.Succeeded || auth.Principal is null)
+ return Problem(OAuthError.FlowNotFound);
+
+ // Read identifiers from claims (no props.Items)
+ var flowIdClaim = auth.Principal.FindFirst("flow_id")?.Value;
+ var providerClaim = auth.Principal.FindFirst("provider")?.Value;
+
+ if (string.IsNullOrWhiteSpace(flowIdClaim) || string.IsNullOrWhiteSpace(providerClaim))
+ {
+ await HttpContext.SignOutAsync(OpenShockAuthSchemes.OAuthFlowScheme);
+ return Problem(OAuthError.FlowNotFound);
+ }
+
+ // Defensive: ensure the snapshot belongs to this provider
+ if (providerClaim != provider)
+ {
+ // Optional: you may also delete the cookie if you consider this a poisoned flow
+ await HttpContext.SignOutAsync(OpenShockAuthSchemes.OAuthFlowScheme);
+ return Problem(OAuthError.ProviderMismatch);
+ }
+
+ return Ok(new OAuthDataResponse
+ {
+ Provider = providerClaim,
+ Email = auth.Principal.FindFirst(ClaimTypes.Email)?.Value,
+ DisplayName = auth.Principal.FindFirst(ClaimTypes.Name)?.Value,
+ ExpiresAt = auth.Ticket.Properties.ExpiresUtc!.Value.UtcDateTime
+ });
+ }
+}
diff --git a/API/Controller/OAuth/HandOff.cs b/API/Controller/OAuth/HandOff.cs
new file mode 100644
index 00000000..db4867a7
--- /dev/null
+++ b/API/Controller/OAuth/HandOff.cs
@@ -0,0 +1,110 @@
+using Microsoft.AspNetCore.Authentication;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.RateLimiting;
+using Microsoft.Extensions.Options;
+using OpenShock.API.Extensions;
+using OpenShock.API.Services.Account;
+using OpenShock.API.Services.OAuthConnection;
+using OpenShock.Common.Authentication;
+using OpenShock.Common.Constants;
+using OpenShock.Common.Errors;
+using OpenShock.Common.Options;
+using OpenShock.Common.Problems;
+using System.Net.Mime;
+using System.Security.Claims;
+
+namespace OpenShock.API.Controller.OAuth;
+
+public sealed partial class OAuthController
+{
+ ///
+ /// Handoff after provider callback. Decides next step (create, link, or direct sign-in).
+ ///
+ ///
+ /// This endpoint is reached after the OAuth middleware processed the provider callback.
+ /// It reads the temp OAuth flow principal and its flow (create/link).
+ /// If an existing connection is found, signs in and redirects home; otherwise redirects the frontend to continue the flow.
+ ///
+ /// Provider key (e.g. discord).
+ ///
+ ///
+ ///
+ /// Redirect to the frontend (create/link) or home on direct sign-in.
+ /// Flow missing or not supported.
+ [EnableRateLimiting("auth")]
+ [HttpGet("{provider}/handoff")]
+ [ProducesResponseType(StatusCodes.Status302Found)]
+ [ProducesResponseType(StatusCodes.Status400BadRequest, MediaTypeNames.Application.Json)]
+ public async Task OAuthHandOff(
+ [FromRoute] string provider,
+ [FromServices] IAccountService accountService,
+ [FromServices] IOAuthConnectionService connectionService,
+ [FromServices] IOptions frontendOptions)
+ {
+ if (!await _schemeProvider.IsSupportedOAuthScheme(provider))
+ return Problem(OAuthError.UnsupportedProvider);
+
+ // Temp external principal (set by OAuth SignInScheme).
+ var auth = await HttpContext.AuthenticateAsync(OpenShockAuthSchemes.OAuthFlowScheme);
+ if (!auth.Succeeded || auth.Principal is null)
+ return Problem(OAuthError.FlowNotFound);
+
+ // Flow is stored in AuthenticationProperties by the authorize step.
+ if (auth.Properties is null || !auth.Properties.Items.TryGetValue("flow", out var flow) || string.IsNullOrWhiteSpace(flow))
+ {
+ await HttpContext.SignOutAsync(OpenShockAuthSchemes.OAuthFlowScheme);
+ return Problem(OAuthError.InternalError);
+ }
+ flow = flow.ToLowerInvariant();
+
+ // External subject is required to resolve/link.
+ var externalId = auth.Principal.FindFirst(ClaimTypes.NameIdentifier)?.Value;
+ if (string.IsNullOrEmpty(externalId))
+ {
+ await HttpContext.SignOutAsync(OpenShockAuthSchemes.OAuthFlowScheme);
+ return Problem(OAuthError.FlowMissingData);
+ }
+
+ var connection = await connectionService.GetByProviderExternalIdAsync(provider, externalId);
+
+ switch (flow)
+ {
+ case AuthConstants.OAuthLoginOrCreateFlow:
+ {
+ if (connection is not null)
+ {
+ // Already linked -> sign in and go home.
+ // TODO: issue your UserSessionCookie/session here for connection.UserId
+ await HttpContext.SignOutAsync(OpenShockAuthSchemes.OAuthFlowScheme);
+ return Redirect("/");
+ }
+
+ var frontendUrl = new UriBuilder(frontendOptions.Value.BaseUrl)
+ {
+ Path = $"oauth/{provider}/create"
+ };
+ return Redirect(frontendUrl.Uri.ToString());
+ }
+
+ case AuthConstants.OAuthLinkFlow:
+ {
+ if (connection is not null)
+ {
+ // TODO: Check if the connection is connected to our account with same externalId (AlreadyLinked), different externalId (AlreadyExists), or to another account (LinkedToAnotherAccount)
+ await HttpContext.SignOutAsync(OpenShockAuthSchemes.OAuthFlowScheme);
+ return Problem(OAuthError.ExternalAlreadyLinked);
+ }
+
+ var frontendUrl = new UriBuilder(frontendOptions.Value.BaseUrl)
+ {
+ Path = $"oauth/{provider}/link"
+ };
+ return Redirect(frontendUrl.Uri.ToString());
+ }
+
+ default:
+ await HttpContext.SignOutAsync(OpenShockAuthSchemes.OAuthFlowScheme);
+ return Problem(OAuthError.UnsupportedFlow);
+ }
+ }
+}
\ No newline at end of file
diff --git a/API/Controller/OAuth/ListProviders.cs b/API/Controller/OAuth/ListProviders.cs
new file mode 100644
index 00000000..bdffb49a
--- /dev/null
+++ b/API/Controller/OAuth/ListProviders.cs
@@ -0,0 +1,21 @@
+using Microsoft.AspNetCore.Authentication;
+using Microsoft.AspNetCore.Mvc;
+using OpenShock.API.Extensions;
+
+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/_ApiController.cs b/API/Controller/OAuth/_ApiController.cs
new file mode 100644
index 00000000..5c83982f
--- /dev/null
+++ b/API/Controller/OAuth/_ApiController.cs
@@ -0,0 +1,29 @@
+using Asp.Versioning;
+using Microsoft.AspNetCore.Authentication;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+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;
+ }
+}
\ No newline at end of file
diff --git a/API/Extensions/AuthenticationSchemeProviderExtensions.cs b/API/Extensions/AuthenticationSchemeProviderExtensions.cs
new file mode 100644
index 00000000..c10ae261
--- /dev/null
+++ b/API/Extensions/AuthenticationSchemeProviderExtensions.cs
@@ -0,0 +1,26 @@
+using Microsoft.AspNetCore.Authentication;
+using OpenShock.Common.Authentication;
+
+namespace OpenShock.API.Extensions;
+
+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 => OpenShockAuthSchemes.OAuth2Schemes.Contains(scheme))
+ .ToArray();
+ }
+ public static async Task IsSupportedOAuthScheme(this IAuthenticationSchemeProvider provider, string scheme)
+ {
+ if (!OpenShockAuthSchemes.OAuth2Schemes.Contains(scheme))
+ return false;
+
+ var schemes = await provider.GetAllSchemesAsync();
+
+ return schemes.Any(s => s.Name == scheme);
+ }
+}
diff --git a/API/Models/Requests/OAuthFinalizeRequest.cs b/API/Models/Requests/OAuthFinalizeRequest.cs
new file mode 100644
index 00000000..a3da8b2a
--- /dev/null
+++ b/API/Models/Requests/OAuthFinalizeRequest.cs
@@ -0,0 +1,19 @@
+using System.ComponentModel.DataAnnotations;
+
+namespace OpenShock.API.Models.Requests;
+
+public sealed class OAuthFinalizeRequest
+{
+ /// Action to perform: "create" or "link".
+ [Required]
+ public required string Action { get; init; }
+
+ /// Desired username (create only). If omitted, a name will be generated from the external profile.
+ public string? Username { get; init; }
+
+ ///
+ /// New account password (create only). If omitted, a strong random password will be generated.
+ /// Your current AccountService requires a password.
+ ///
+ public string? Password { get; init; }
+}
\ No newline at end of file
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/OAuthDataResponse.cs b/API/Models/Response/OAuthDataResponse.cs
new file mode 100644
index 00000000..7f4ade5b
--- /dev/null
+++ b/API/Models/Response/OAuthDataResponse.cs
@@ -0,0 +1,10 @@
+namespace OpenShock.API.Models.Response;
+
+// what we return to frontend at /oauth/discord/data
+public sealed class OAuthDataResponse
+{
+ 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/Models/Response/OAuthFinalizeResponse.cs b/API/Models/Response/OAuthFinalizeResponse.cs
new file mode 100644
index 00000000..25f57840
--- /dev/null
+++ b/API/Models/Response/OAuthFinalizeResponse.cs
@@ -0,0 +1,16 @@
+namespace OpenShock.API.Models.Response;
+
+public sealed class OAuthFinalizeResponse
+{
+ /// "ok" on success; otherwise not returned (problem details emitted).
+ public string Status { get; init; } = "ok";
+
+ /// The provider key that was processed.
+ public required string Provider { get; init; }
+
+ /// The external account id that was linked.
+ public required string ExternalId { get; init; }
+
+ /// When action=create, the username of the newly created account.
+ public string? Username { get; init; }
+}
\ 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..00bc7a9d
--- /dev/null
+++ b/API/Options/OAuth/DiscordOAuthOptions.cs
@@ -0,0 +1,14 @@
+
+
+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; }
+ public required PathString CallbackPath { get; init; }
+ public required PathString AccessDeniedPath { get; init; }
+ public required string[] Scopes { get; init; }
+}
\ No newline at end of file
diff --git a/API/Program.cs b/API/Program.cs
index 3684799f..397bcd6c 100644
--- a/API/Program.cs
+++ b/API/Program.cs
@@ -1,11 +1,15 @@
+using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Http.Connections;
using Microsoft.Extensions.Options;
+using OpenShock.API.Options.OAuth;
using OpenShock.API.Realtime;
using OpenShock.API.Services;
using OpenShock.API.Services.Account;
using OpenShock.API.Services.Email;
+using OpenShock.API.Services.OAuthConnection;
using OpenShock.API.Services.UserService;
using OpenShock.Common;
+using OpenShock.Common.Authentication;
using OpenShock.Common.DeviceControl;
using OpenShock.Common.Extensions;
using OpenShock.Common.Hubs;
@@ -35,7 +39,33 @@
builder.Services.AddOpenShockMemDB(redisConfig);
builder.Services.AddOpenShockDB(databaseConfig);
-builder.Services.AddOpenShockServices();
+builder.Services.AddOpenShockServices(auth => auth
+ .AddCookie(OpenShockAuthSchemes.OAuthFlowScheme, o =>
+ {
+ o.Cookie.Name = OpenShockAuthSchemes.OAuthFlowCookieName;
+ o.ExpireTimeSpan = TimeSpan.FromMinutes(10);
+ o.SlidingExpiration = false;
+ })
+ .AddDiscord(OpenShockAuthSchemes.DiscordScheme, o =>
+ {
+ o.SignInScheme = OpenShockAuthSchemes.OAuthFlowScheme;
+
+ var options = builder.Configuration.GetRequiredSection(DiscordOAuthOptions.SectionName).Get()!;
+
+ o.ClientId = options.ClientId;
+ o.ClientSecret = options.ClientSecret;
+ o.CallbackPath = options.CallbackPath;
+ o.AccessDeniedPath = options.AccessDeniedPath;
+ foreach (var scope in options.Scopes) o.Scope.Add(scope);
+
+ o.Prompt = "none";
+ o.SaveTokens = true;
+
+ o.ClaimActions.MapJsonKey("email-verified", "verified");
+
+ o.Validate();
+ })
+);
builder.Services.AddSignalR()
.AddOpenShockStackExchangeRedis(options => { options.Configuration = redisConfig; })
@@ -50,6 +80,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 9bd816f9..2ec98d47 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,70 @@ 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)
+ {
+ 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();
+
+ try
+ {
+ var user = new User
+ {
+ Id = Guid.CreateVersion7(),
+ Name = username,
+ Email = email,
+ PasswordHash = null, // OAuth-only account
+ ActivatedAt = DateTime.UtcNow // no activation flow
+ };
+
+ _db.Users.Add(user);
+ await _db.SaveChangesAsync();
+
+ // Link external identity
+ _db.UserOAuthConnections.Add(new UserOAuthConnection
+ {
+ UserId = user.Id,
+ ProviderKey = provider,
+ ExternalId = providerAccountId,
+ DisplayName = providerAccountName
+ });
+
+ await _db.SaveChangesAsync();
+
+ await tx.CommitAsync();
+
+ // Ensure ActivatedAt <= CreatedAt (optional monotonic tidy-up)
+ if (user.CreatedAt > user.ActivatedAt)
+ {
+ user.ActivatedAt = user.CreatedAt;
+ await _db.SaveChangesAsync();
+ }
+
+ return new Success(user);
+ }
+ catch (DbUpdateException ex) when (ex.InnerException is PostgresException { SqlState: "23505" })
+ {
+ // Unique constraint hit: either username/email already exists, or (provider, externalId) is already linked.
+ await tx.RollbackAsync();
+ return new AccountWithEmailOrUsernameExists();
+ }
+ }
+
public async Task TryActivateAccountAsync(string secret, CancellationToken cancellationToken = default)
{
var hash = HashingUtils.HashToken(secret);
@@ -250,7 +315,7 @@ public async Task
- public async Task> CreateUserLoginSessionAsync(string usernameOrEmail, string password,
+ public async Task> CreateUserLoginSessionAsync(string usernameOrEmail, string password,
LoginContext loginContext, CancellationToken cancellationToken = default)
{
var lowercaseUsernameOrEmail = usernameOrEmail.ToLowerInvariant();
@@ -263,14 +328,18 @@ 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..3c9b66f1 100644
--- a/API/Services/Account/IAccountService.cs
+++ b/API/Services/Account/IAccountService.cs
@@ -28,6 +28,19 @@ 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);
+
///
///
///
@@ -50,7 +63,7 @@ public interface IAccountService
///
///
///
- public Task> CreateUserLoginSessionAsync(string usernameOrEmail, string password, LoginContext loginContext, CancellationToken cancellationToken = default);
+ public Task> CreateUserLoginSessionAsync(string usernameOrEmail, string password, LoginContext loginContext, 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..2706a479
--- /dev/null
+++ b/API/Services/OAuthConnection/IOAuthConnectionService.cs
@@ -0,0 +1,15 @@
+using OpenShock.Common.OpenShockDb;
+
+namespace OpenShock.API.Services.OAuthConnection;
+
+///
+/// Manages external OAuth connections for users.
+///
+public interface IOAuthConnectionService
+{
+ Task GetConnectionsAsync(Guid userId);
+ Task GetByProviderExternalIdAsync(string provider, string providerAccountId);
+ Task HasConnectionAsync(Guid userId, string provider);
+ Task TryAddConnectionAsync(Guid userId, string provider, string providerAccountId, string? providerAccountName);
+ Task TryRemoveConnectionAsync(Guid userId, string provider);
+}
\ 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..572e88f7
--- /dev/null
+++ b/API/Services/OAuthConnection/OAuthConnectionService.cs
@@ -0,0 +1,71 @@
+using Microsoft.EntityFrameworkCore;
+using Npgsql;
+using OpenShock.API.Services.OAuthConnection;
+using OpenShock.Common.OpenShockDb;
+
+namespace OpenShock.API.Services.Account;
+
+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)
+ {
+ return await _db.UserOAuthConnections
+ .AsNoTracking()
+ .Where(c => c.UserId == userId)
+ .ToArrayAsync();
+ }
+
+ public async Task GetByProviderExternalIdAsync(string provider, string providerAccountId)
+ {
+ var p = provider.ToLowerInvariant();
+ return await _db.UserOAuthConnections
+ .FirstOrDefaultAsync(c => c.ProviderKey == p && c.ExternalId == providerAccountId);
+ }
+
+ public async Task HasConnectionAsync(Guid userId, string provider)
+ {
+ var p = provider.ToLowerInvariant();
+ return await _db.UserOAuthConnections.AnyAsync(c => c.UserId == userId && c.ProviderKey == p);
+ }
+
+ public async Task TryAddConnectionAsync(Guid userId, string provider, string providerAccountId, string? providerAccountName)
+ {
+ try
+ {
+ _db.UserOAuthConnections.Add(new UserOAuthConnection
+ {
+ UserId = userId,
+ ProviderKey = provider.ToLowerInvariant(),
+ ExternalId = providerAccountId,
+ DisplayName = providerAccountName
+ });
+ await _db.SaveChangesAsync();
+ 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)
+ {
+ var p = provider.ToLowerInvariant();
+ var nDeleted = await _db.UserOAuthConnections
+ .Where(c => c.UserId == userId && c.ProviderKey == p)
+ .ExecuteDeleteAsync();
+
+ return nDeleted > 0;
+ }
+}
diff --git a/Common/Authentication/AuthenticationHandlers/ApiTokenAuthentication.cs b/Common/Authentication/AuthenticationHandlers/ApiTokenAuthentication.cs
index 9ac2bfd0..0d4d8a95 100644
--- a/Common/Authentication/AuthenticationHandlers/ApiTokenAuthentication.cs
+++ b/Common/Authentication/AuthenticationHandlers/ApiTokenAuthentication.cs
@@ -66,7 +66,7 @@ protected override async Task HandleAuthenticateAsync()
List claims = new List(3 + tokenDto.Permissions.Count)
{
- new(ClaimTypes.AuthenticationMethod, OpenShockAuthSchemes.ApiToken),
+ new(ClaimTypes.AuthenticationMethod, Scheme.Name),
new(ClaimTypes.NameIdentifier, tokenDto.User.Id.ToString()),
new(OpenShockAuthClaims.ApiTokenId, tokenDto.Id.ToString())
};
@@ -78,8 +78,6 @@ protected override async Task HandleAuthenticateAsync()
var ident = new ClaimsIdentity(claims, nameof(ApiTokenAuthentication));
- Context.User = new ClaimsPrincipal(ident);
-
var ticket = new AuthenticationTicket(new ClaimsPrincipal(ident), Scheme.Name);
return AuthenticateResult.Success(ticket);
diff --git a/Common/Authentication/AuthenticationHandlers/HubAuthentication.cs b/Common/Authentication/AuthenticationHandlers/HubAuthentication.cs
index 65f4557c..19801918 100644
--- a/Common/Authentication/AuthenticationHandlers/HubAuthentication.cs
+++ b/Common/Authentication/AuthenticationHandlers/HubAuthentication.cs
@@ -60,11 +60,13 @@ protected override async Task HandleAuthenticateAsync()
_authService.CurrentClient = device;
Claim[] claims = [
- new(ClaimTypes.AuthenticationMethod, OpenShockAuthSchemes.HubToken),
+ new(ClaimTypes.AuthenticationMethod, Scheme.Name),
new Claim(ClaimTypes.NameIdentifier, device.OwnerId.ToString()),
new Claim(OpenShockAuthClaims.HubId, _authService.CurrentClient.Id.ToString()),
];
+
var ident = new ClaimsIdentity(claims, nameof(HubAuthentication));
+
var ticket = new AuthenticationTicket(new ClaimsPrincipal(ident), Scheme.Name);
return AuthenticateResult.Success(ticket);
diff --git a/Common/Authentication/AuthenticationHandlers/UserSessionAuthentication.cs b/Common/Authentication/AuthenticationHandlers/UserSessionAuthentication.cs
index 83918108..511f5b7d 100644
--- a/Common/Authentication/AuthenticationHandlers/UserSessionAuthentication.cs
+++ b/Common/Authentication/AuthenticationHandlers/UserSessionAuthentication.cs
@@ -81,16 +81,14 @@ protected override async Task HandleAuthenticateAsync()
_userReferenceService.AuthReference = session;
List claims = [
- new(ClaimTypes.AuthenticationMethod, OpenShockAuthSchemes.UserSessionCookie),
+ new(ClaimTypes.AuthenticationMethod, Scheme.Name),
new(ClaimTypes.NameIdentifier, retrievedUser.Id.ToString()),
];
claims.AddRange(retrievedUser.Roles.Select(r => new Claim(ClaimTypes.Role, r.ToString())));
var ident = new ClaimsIdentity(claims, nameof(UserSessionAuthentication));
-
- Context.User = new ClaimsPrincipal(ident);
-
+
var ticket = new AuthenticationTicket(new ClaimsPrincipal(ident), Scheme.Name);
return AuthenticateResult.Success(ticket);
diff --git a/Common/Authentication/OpenShockAuthSchemes.cs b/Common/Authentication/OpenShockAuthSchemes.cs
index 7d3c8792..27ebe9b4 100644
--- a/Common/Authentication/OpenShockAuthSchemes.cs
+++ b/Common/Authentication/OpenShockAuthSchemes.cs
@@ -6,5 +6,10 @@ public static class OpenShockAuthSchemes
public const string ApiToken = "ApiToken";
public const string HubToken = "HubToken";
+ public const string OAuthFlowScheme = "OAuthFlowCookie";
+ public const string OAuthFlowCookieName = ".OpenShock.OAuthFlow";
+ public const string DiscordScheme = "discord";
+ public static readonly string[] OAuth2Schemes = [DiscordScheme];
+
public const string UserSessionApiTokenCombo = $"{UserSessionCookie},{ApiToken}";
}
\ No newline at end of file
diff --git a/Common/Common.csproj b/Common/Common.csproj
index 0c57f624..a5cc32c4 100644
--- a/Common/Common.csproj
+++ b/Common/Common.csproj
@@ -13,6 +13,7 @@
runtime; build; native; contentfiles; analyzers; buildtransitive
+
diff --git a/Common/Constants/AuthConstants.cs b/Common/Constants/AuthConstants.cs
index 3f1153d5..5d0e2651 100644
--- a/Common/Constants/AuthConstants.cs
+++ b/Common/Constants/AuthConstants.cs
@@ -7,6 +7,9 @@ public static class AuthConstants
public const string ApiTokenHeaderName = "OpenShockToken";
public const string HubTokenHeaderName = "DeviceToken";
+ public const string OAuthLoginOrCreateFlow = "login-or-create";
+ public const string OAuthLinkFlow = "link";
+
public const int GeneratedTokenLength = 32;
public const int ApiTokenLength = 64;
}
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/OAuthError.cs b/Common/Errors/OAuthError.cs
new file mode 100644
index 00000000..a48f9b3f
--- /dev/null
+++ b/Common/Errors/OAuthError.cs
@@ -0,0 +1,54 @@
+using OpenShock.Common.Problems;
+using System.Net;
+
+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 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);
+}
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