diff --git a/DysonNetwork.Shared/DysonNetwork.Shared.csproj b/DysonNetwork.Shared/DysonNetwork.Shared.csproj
new file mode 100644
index 0000000..b684f7a
--- /dev/null
+++ b/DysonNetwork.Shared/DysonNetwork.Shared.csproj
@@ -0,0 +1,22 @@
+
+
+
+ net9.0
+ enable
+ enable
+
+
+
+
+
+
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
diff --git a/DysonNetwork.Shared/Protos/account.proto b/DysonNetwork.Shared/Protos/account.proto
new file mode 100644
index 0000000..7cb6696
--- /dev/null
+++ b/DysonNetwork.Shared/Protos/account.proto
@@ -0,0 +1,28 @@
+syntax = "proto3";
+
+package dyson_network.sphere.account;
+
+import "google/protobuf/empty.proto";
+
+option csharp_namespace = "DysonNetwork.Sphere.Account.Proto";
+
+service AccountService {
+ // Retrieves the current user's account information
+ rpc GetAccount(google.protobuf.Empty) returns (AccountResponse);
+
+ // Updates the current user's account information
+ rpc UpdateAccount(UpdateAccountRequest) returns (AccountResponse);
+}
+
+message AccountResponse {
+ string id = 1;
+ string username = 2;
+ string email = 3;
+ string display_name = 4;
+}
+
+message UpdateAccountRequest {
+ // Fields to update
+ optional string email = 1;
+ optional string display_name = 2;
+}
diff --git a/DysonNetwork.Shared/Protos/auth.proto b/DysonNetwork.Shared/Protos/auth.proto
new file mode 100644
index 0000000..a119e41
--- /dev/null
+++ b/DysonNetwork.Shared/Protos/auth.proto
@@ -0,0 +1,55 @@
+syntax = "proto3";
+
+package dyson_network.sphere.auth;
+
+import "google/protobuf/empty.proto";
+import "google/protobuf/timestamp.proto";
+
+option csharp_namespace = "DysonNetwork.Sphere.Auth.Proto";
+
+service AuthService {
+ // Standard username/password login
+ rpc Login(LoginRequest) returns (LoginResponse);
+
+ // Introspects an OAuth 2.0 access token.
+ rpc IntrospectToken(IntrospectTokenRequest) returns (IntrospectionResponse);
+
+ // Logs out the current session
+ rpc Logout(google.protobuf.Empty) returns (google.protobuf.Empty);
+}
+
+message LoginRequest {
+ string username = 1;
+ string password = 2;
+ // Optional: for 2FA
+ string two_factor_code = 3;
+}
+
+message LoginResponse {
+ string access_token = 1;
+ string refresh_token = 2;
+ int64 expires_in = 3;
+}
+
+message IntrospectTokenRequest {
+ string token = 1;
+ // Optional: token_type_hint can be "access_token" or "refresh_token"
+ string token_type_hint = 2;
+}
+
+message IntrospectionResponse {
+ // Indicates whether or not the token is currently active.
+ bool active = 1;
+ // A JSON string containing the claims of the token.
+ string claims = 2;
+ // The client identifier for the OAuth 2.0 client that requested the token.
+ string client_id = 3;
+ // The username of the resource owner who authorized the token.
+ string username = 4;
+ // The scope of the access token.
+ string scope = 5;
+ // The time at which the token was issued.
+ google.protobuf.Timestamp iat = 6;
+ // The time at which the token expires.
+ google.protobuf.Timestamp exp = 7;
+}
diff --git a/DysonNetwork.Sphere/Account/AccountGrpcService.cs b/DysonNetwork.Sphere/Account/AccountGrpcService.cs
new file mode 100644
index 0000000..d72e4cc
--- /dev/null
+++ b/DysonNetwork.Sphere/Account/AccountGrpcService.cs
@@ -0,0 +1,87 @@
+using DysonNetwork.Sphere.Account.Proto;
+using Grpc.Core;
+using Google.Protobuf.WellKnownTypes;
+using Microsoft.EntityFrameworkCore;
+using DysonNetwork.Sphere.Auth;
+
+namespace DysonNetwork.Sphere.Account;
+
+public class AccountGrpcService : DysonNetwork.Sphere.Account.Proto.AccountService.AccountServiceBase
+{
+ private readonly AppDatabase _db;
+ private readonly AuthService _auth;
+
+ public AccountGrpcService(AppDatabase db, AuthService auth)
+ {
+ _db = db;
+ _auth = auth;
+ }
+
+ public override async Task GetAccount(Empty request, ServerCallContext context)
+ {
+ var account = await GetAccountFromContext(context);
+ return ToAccountResponse(account);
+ }
+
+ public override async Task UpdateAccount(UpdateAccountRequest request, ServerCallContext context)
+ {
+ var account = await GetAccountFromContext(context);
+
+ if (request.Email != null)
+ {
+ var emailContact = await _db.AccountContacts.FirstOrDefaultAsync(c => c.AccountId == account.Id && c.Type == AccountContactType.Email);
+ if (emailContact != null)
+ {
+ emailContact.Content = request.Email;
+ }
+ else
+ {
+ account.Contacts.Add(new AccountContact { Type = AccountContactType.Email, Content = request.Email });
+ }
+ }
+
+ if (request.DisplayName != null)
+ {
+ account.Nick = request.DisplayName;
+ }
+
+ await _db.SaveChangesAsync();
+
+ return ToAccountResponse(account);
+ }
+
+ private async Task GetAccountFromContext(ServerCallContext context)
+ {
+ var authorizationHeader = context.RequestHeaders.FirstOrDefault(h => h.Key == "authorization");
+ if (authorizationHeader == null)
+ {
+ throw new RpcException(new Grpc.Core.Status(StatusCode.Unauthenticated, "Missing authorization header."));
+ }
+
+ var token = authorizationHeader.Value.Replace("Bearer ", "");
+ if (!_auth.ValidateToken(token, out var sessionId))
+ {
+ throw new RpcException(new Grpc.Core.Status(StatusCode.Unauthenticated, "Invalid token."));
+ }
+
+ var session = await _db.AuthSessions.Include(s => s.Account).ThenInclude(a => a.Contacts).FirstOrDefaultAsync(s => s.Id == sessionId);
+ if (session == null)
+ {
+ throw new RpcException(new Grpc.Core.Status(StatusCode.Unauthenticated, "Session not found."));
+ }
+
+ return session.Account;
+ }
+
+ private AccountResponse ToAccountResponse(DysonNetwork.Sphere.Account.Account account)
+ {
+ var emailContact = account.Contacts.FirstOrDefault(c => c.Type == AccountContactType.Email);
+ return new AccountResponse
+ {
+ Id = account.Id.ToString(),
+ Username = account.Name,
+ Email = emailContact?.Content ?? "",
+ DisplayName = account.Nick
+ };
+ }
+}
diff --git a/DysonNetwork.Sphere/Auth/AuthGrpcService.cs b/DysonNetwork.Sphere/Auth/AuthGrpcService.cs
new file mode 100644
index 0000000..2e6f7b3
--- /dev/null
+++ b/DysonNetwork.Sphere/Auth/AuthGrpcService.cs
@@ -0,0 +1,103 @@
+using DysonNetwork.Sphere.Auth.Proto;
+using Grpc.Core;
+using Google.Protobuf.WellKnownTypes;
+using DysonNetwork.Sphere.Account;
+using Microsoft.EntityFrameworkCore;
+using NodaTime;
+using System.Text.Json;
+
+namespace DysonNetwork.Sphere.Auth;
+
+public class AuthGrpcService : DysonNetwork.Sphere.Auth.Proto.AuthService.AuthServiceBase
+{
+ private readonly AppDatabase _db;
+ private readonly AccountService _accounts;
+ private readonly AuthService _auth;
+
+ public AuthGrpcService(AppDatabase db, AccountService accounts, AuthService auth)
+ {
+ _db = db;
+ _accounts = accounts;
+ _auth = auth;
+ }
+
+ public override async Task Login(LoginRequest request, ServerCallContext context)
+ {
+ var account = await _accounts.LookupAccount(request.Username);
+ if (account == null)
+ {
+ throw new RpcException(new Grpc.Core.Status(StatusCode.NotFound, "Account not found."));
+ }
+
+ var factor = await _db.AccountAuthFactors.FirstOrDefaultAsync(f => f.AccountId == account.Id && f.Type == AccountAuthFactorType.Password);
+ if (factor == null || !factor.VerifyPassword(request.Password))
+ {
+ throw new RpcException(new Grpc.Core.Status(StatusCode.Unauthenticated, "Invalid credentials."));
+ }
+
+ var session = new Session
+ {
+ LastGrantedAt = Instant.FromDateTimeUtc(DateTime.UtcNow),
+ ExpiredAt = Instant.FromDateTimeUtc(DateTime.UtcNow.AddDays(30)),
+ Account = account,
+ Challenge = new Challenge() // Create a dummy challenge
+ };
+
+ _db.AuthSessions.Add(session);
+ await _db.SaveChangesAsync();
+
+ var token = _auth.CreateToken(session);
+
+ return new LoginResponse
+ {
+ AccessToken = token,
+ ExpiresIn = (long)(session.ExpiredAt.Value - session.LastGrantedAt.Value).TotalSeconds
+ };
+ }
+
+ public override async Task IntrospectToken(IntrospectTokenRequest request, ServerCallContext context)
+ {
+ if (_auth.ValidateToken(request.Token, out var sessionId))
+ {
+ var session = await _db.AuthSessions
+ .Include(s => s.Account)
+ .Include(s => s.Challenge)
+ .FirstOrDefaultAsync(s => s.Id == sessionId);
+
+ if (session != null)
+ {
+ return new IntrospectionResponse
+ {
+ Active = true,
+ Claims = JsonSerializer.Serialize(new { sub = session.AccountId }),
+ ClientId = session.AppId?.ToString() ?? "",
+ Username = session.Account.Name,
+ Scope = string.Join(" ", session.Challenge.Scopes),
+ Iat = Timestamp.FromDateTime(session.CreatedAt.ToDateTimeUtc()),
+ Exp = Timestamp.FromDateTime(session.ExpiredAt?.ToDateTimeUtc() ?? DateTime.MaxValue)
+ };
+ }
+ }
+
+ return new IntrospectionResponse { Active = false };
+ }
+
+ public override async Task Logout(Empty request, ServerCallContext context)
+ {
+ var authorizationHeader = context.RequestHeaders.FirstOrDefault(h => h.Key == "authorization");
+ if (authorizationHeader != null)
+ {
+ var token = authorizationHeader.Value.Replace("Bearer ", "");
+ if (_auth.ValidateToken(token, out var sessionId))
+ {
+ var session = await _db.AuthSessions.FindAsync(sessionId);
+ if (session != null)
+ {
+ _db.AuthSessions.Remove(session);
+ await _db.SaveChangesAsync();
+ }
+ }
+ }
+ return new Empty();
+ }
+}
diff --git a/DysonNetwork.Sphere/DysonNetwork.Sphere.csproj b/DysonNetwork.Sphere/DysonNetwork.Sphere.csproj
index 0f39fb9..c2cf5c4 100644
--- a/DysonNetwork.Sphere/DysonNetwork.Sphere.csproj
+++ b/DysonNetwork.Sphere/DysonNetwork.Sphere.csproj
@@ -164,4 +164,13 @@
<_ContentIncludedByDefault Remove="app\publish\package.json" />
+
+
+
+
+
+
+
+
+
diff --git a/DysonNetwork.Sphere/Program.cs b/DysonNetwork.Sphere/Program.cs
index 452e629..339ea33 100644
--- a/DysonNetwork.Sphere/Program.cs
+++ b/DysonNetwork.Sphere/Program.cs
@@ -17,6 +17,9 @@ builder.Services.AddAppRateLimiting();
builder.Services.AddAppAuthentication();
builder.Services.AddAppSwagger();
+// Add gRPC services
+builder.Services.AddGrpc();
+
// Add file storage
builder.Services.AddAppFileStorage(builder.Configuration);
@@ -44,4 +47,8 @@ var tusDiskStore = app.Services.GetRequiredService();
// Configure application middleware pipeline
app.ConfigureAppMiddleware(builder.Configuration, tusDiskStore);
+// Map gRPC services
+app.MapGrpcService();
+app.MapGrpcService();
+
app.Run();
\ No newline at end of file
diff --git a/DysonNetwork.Sphere/Protos/account.proto b/DysonNetwork.Sphere/Protos/account.proto
new file mode 100644
index 0000000..7cb6696
--- /dev/null
+++ b/DysonNetwork.Sphere/Protos/account.proto
@@ -0,0 +1,28 @@
+syntax = "proto3";
+
+package dyson_network.sphere.account;
+
+import "google/protobuf/empty.proto";
+
+option csharp_namespace = "DysonNetwork.Sphere.Account.Proto";
+
+service AccountService {
+ // Retrieves the current user's account information
+ rpc GetAccount(google.protobuf.Empty) returns (AccountResponse);
+
+ // Updates the current user's account information
+ rpc UpdateAccount(UpdateAccountRequest) returns (AccountResponse);
+}
+
+message AccountResponse {
+ string id = 1;
+ string username = 2;
+ string email = 3;
+ string display_name = 4;
+}
+
+message UpdateAccountRequest {
+ // Fields to update
+ optional string email = 1;
+ optional string display_name = 2;
+}
diff --git a/DysonNetwork.Sphere/Protos/auth.proto b/DysonNetwork.Sphere/Protos/auth.proto
new file mode 100644
index 0000000..a119e41
--- /dev/null
+++ b/DysonNetwork.Sphere/Protos/auth.proto
@@ -0,0 +1,55 @@
+syntax = "proto3";
+
+package dyson_network.sphere.auth;
+
+import "google/protobuf/empty.proto";
+import "google/protobuf/timestamp.proto";
+
+option csharp_namespace = "DysonNetwork.Sphere.Auth.Proto";
+
+service AuthService {
+ // Standard username/password login
+ rpc Login(LoginRequest) returns (LoginResponse);
+
+ // Introspects an OAuth 2.0 access token.
+ rpc IntrospectToken(IntrospectTokenRequest) returns (IntrospectionResponse);
+
+ // Logs out the current session
+ rpc Logout(google.protobuf.Empty) returns (google.protobuf.Empty);
+}
+
+message LoginRequest {
+ string username = 1;
+ string password = 2;
+ // Optional: for 2FA
+ string two_factor_code = 3;
+}
+
+message LoginResponse {
+ string access_token = 1;
+ string refresh_token = 2;
+ int64 expires_in = 3;
+}
+
+message IntrospectTokenRequest {
+ string token = 1;
+ // Optional: token_type_hint can be "access_token" or "refresh_token"
+ string token_type_hint = 2;
+}
+
+message IntrospectionResponse {
+ // Indicates whether or not the token is currently active.
+ bool active = 1;
+ // A JSON string containing the claims of the token.
+ string claims = 2;
+ // The client identifier for the OAuth 2.0 client that requested the token.
+ string client_id = 3;
+ // The username of the resource owner who authorized the token.
+ string username = 4;
+ // The scope of the access token.
+ string scope = 5;
+ // The time at which the token was issued.
+ google.protobuf.Timestamp iat = 6;
+ // The time at which the token expires.
+ google.protobuf.Timestamp exp = 7;
+}
diff --git a/DysonNetwork.sln b/DysonNetwork.sln
index ea7c8d0..2c30e2d 100644
--- a/DysonNetwork.sln
+++ b/DysonNetwork.sln
@@ -7,6 +7,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution
compose.yaml = compose.yaml
EndProjectSection
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DysonNetwork.Shared", "DysonNetwork.Shared\DysonNetwork.Shared.csproj", "{C24C6C96-2D99-4D0C-950B-4C3D8B55E930}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -17,5 +19,9 @@ Global
{CFF62EFA-F4C2-4FC7-8D97-25570B4DB452}.Debug|Any CPU.Build.0 = Debug|Any CPU
{CFF62EFA-F4C2-4FC7-8D97-25570B4DB452}.Release|Any CPU.ActiveCfg = Release|Any CPU
{CFF62EFA-F4C2-4FC7-8D97-25570B4DB452}.Release|Any CPU.Build.0 = Release|Any CPU
+ {C24C6C96-2D99-4D0C-950B-4C3D8B55E930}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {C24C6C96-2D99-4D0C-950B-4C3D8B55E930}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {C24C6C96-2D99-4D0C-950B-4C3D8B55E930}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {C24C6C96-2D99-4D0C-950B-4C3D8B55E930}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
EndGlobal