diff --git a/DysonNetwork.Pass/Auth/AuthController.cs b/DysonNetwork.Pass/Auth/AuthController.cs index d6aa9b3..a321732 100644 --- a/DysonNetwork.Pass/Auth/AuthController.cs +++ b/DysonNetwork.Pass/Auth/AuthController.cs @@ -275,6 +275,14 @@ public class AuthController( public string Token { get; set; } = string.Empty; } + public class NewSessionRequest + { + [Required] [MaxLength(512)] public string DeviceId { get; set; } = null!; + [MaxLength(1024)] public string? DeviceName { get; set; } + [Required] public DysonNetwork.Shared.Models.ClientPlatform Platform { get; set; } + public Instant? ExpiredAt { get; set; } + } + [HttpPost("token")] public async Task> ExchangeToken([FromBody] TokenExchangeRequest request) { @@ -325,4 +333,35 @@ public class AuthController( }); return Ok(); } + + [HttpPost("login/session")] + [Microsoft.AspNetCore.Authorization.Authorize] // Use full namespace to avoid ambiguity with DysonNetwork.Pass.Permission.Authorize + public async Task> LoginFromSession([FromBody] NewSessionRequest request) + { + if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser || + HttpContext.Items["CurrentSession"] is not Shared.Models.SnAuthSession currentSession) return Unauthorized(); + + var newSession = await auth.CreateSessionFromParentAsync( + currentSession, + request.DeviceId, + request.DeviceName, + request.Platform, + request.ExpiredAt + ); + + var tk = auth.CreateToken(newSession); + + // Set cookie using HttpContext, similar to CreateSessionAndIssueToken + var cookieDomain = _cookieDomain; + HttpContext.Response.Cookies.Append(AuthConstants.CookieTokenName, tk, new CookieOptions + { + HttpOnly = true, + Secure = true, + SameSite = SameSiteMode.Lax, + Domain = cookieDomain, + Expires = request.ExpiredAt?.ToDateTimeOffset() ?? DateTime.UtcNow.AddYears(20) + }); + + return Ok(new TokenExchangeResponse { Token = tk }); + } } diff --git a/DysonNetwork.Pass/Auth/AuthService.cs b/DysonNetwork.Pass/Auth/AuthService.cs index c892680..9497ded 100644 --- a/DysonNetwork.Pass/Auth/AuthService.cs +++ b/DysonNetwork.Pass/Auth/AuthService.cs @@ -664,4 +664,40 @@ public class AuthService( return Convert.FromBase64String(padded); } + + /// + /// Creates a new session derived from an existing parent session. + /// + /// The existing session from which the new session is derived. + /// The ID of the device for the new session. + /// The name of the device for the new session. + /// The platform of the device for the new session. + /// Optional: The expiration time for the new session. + /// The newly created SnAuthSession. + public async Task CreateSessionFromParentAsync( + SnAuthSession parentSession, + string deviceId, + string? deviceName, + ClientPlatform platform, + Instant? expiredAt = null + ) + { + var device = await GetOrCreateDeviceAsync(parentSession.AccountId, deviceId, deviceName, platform); + + var now = SystemClock.Instance.GetCurrentInstant(); + var session = new SnAuthSession + { + AccountId = parentSession.AccountId, + CreatedAt = now, + LastGrantedAt = now, + ExpiredAt = expiredAt, + ParentSessionId = parentSession.Id, + ClientId = device.Id, + }; + + db.AuthSessions.Add(session); + await db.SaveChangesAsync(); + + return session; + } } diff --git a/docs/WebLocalCredentialSharing.md b/docs/WebLocalCredentialSharing.md new file mode 100644 index 0000000..797e78a --- /dev/null +++ b/docs/WebLocalCredentialSharing.md @@ -0,0 +1,205 @@ +# Web to Local Credential Sharing for Flutter Desktop Apps + +This document outlines the essential features and concepts for implementing secure web to local credential sharing for Flutter desktop applications. The goal is to allow a web application to establish an authenticated session with a running desktop application, leveraging the desktop app's existing authentication and maintaining a secure, hierarchical session structure. + +## Core Concepts from DysonNetwork.Pass Refactoring + +When accessing the Pass service through the Gateway, replace the `/api` with `/pass` + +The recent refactoring of the authentication system in `DysonNetwork.Pass` introduces key mechanisms that directly support this web-to-local credential sharing: + +1. **Parent/Sub-Sessions (`SnAuthSession.ParentSessionId`)**: + * The `SnAuthSession` model now includes a `ParentSessionId` field. This allows an authenticated session to explicitly declare that it was derived from another session. + * This is crucial for the web-to-local flow, as the web session can be established as a child of the desktop app's primary session. + +2. **Recursive Session Revocation**: + * The `AuthService.RevokeSessionAsync` method has been updated to recursively revoke all child sessions (and their children) when a parent session is logged out. + * This ensures that if a user logs out of their desktop application, all web sessions that were derived from that desktop session are also automatically invalidated, enhancing security and maintaining consistency. + +3. **Login from Existing Session API (`AuthController.LoginFromSession`)**: + * A new API endpoint `POST /api/auth/login/session` has been added to `AuthController`. + * This endpoint is designed to create a new `SnAuthSession` (and issue a corresponding authentication token/cookie) by leveraging an *existing* authenticated session. + * It takes device information (`DeviceId`, `DeviceName`, `Platform`, `ExpiredAt`) and the `ParentSessionId` is implicitly set to the `currentSession` available in the `HttpContext`. + * This endpoint is the server-side counterpart to the desktop app's `/exchange` endpoint, allowing the desktop app to request a new, child session for the web application. + +## Integration into the Web-to-Local Flow + +The `AuthController.LoginFromSession` API endpoint plays a central role in the web-to-local credential sharing mechanism. After the Flutter desktop app's local HTTP server receives and verifies a server-signed challenge from the web app (via its `/exchange` endpoint), the desktop app would then call this `LoginFromSession` API endpoint. + +By making this call: +* The desktop app, being already authenticated with the server, provides its active session context. +* The `LoginFromSession` endpoint uses this context to create a *new* session for the web application. +* This new web session is automatically linked to the desktop app's session via `ParentSessionId`. +* A new web session token (e.g., a JWT) is issued for the web app. + +This setup ensures that: +* The web session is securely tied to the desktop session. +* The web session benefits from the recursive revocation logic, meaning if the desktop app session is terminated, the web session is also automatically invalidated. + +--- + +## ✅ Feature Checklist for the Flutter Desktop App + +1. **Localhost HTTP Server** + + Your Flutter desktop app must include: + * A lightweight HTTP server (`dart:io HttpServer`) + * Bind to `127.0.0.1` only (never `0.0.0.0`) + * Use a random port on startup (e.g., `40000–60000`) + * Store this port in memory + + Endpoints required: + 1. `GET /alive` + * Used by the web app to detect that the desktop app is running + * Returns JSON: `{ "status": "ok", "challenge": "" }` + 2. `POST /exchange` + * Web app sends the server-signed challenge back + * Desktop verifies and replies with a signed token/session + * **Crucially, this is where the desktop app would call the `POST /api/auth/login/session` endpoint on the backend, using its existing session to create a new sub-session for the web app.** + 3. `POST /handshake/done` (optional) + * For cleanup, closing UI, etc. + 4. `GET /handshake` (optional) + * For some client information and ensure it's Solian's app + +--- + +2. **Challenge/Response Security System** + + To avoid any malicious website calling your localhost server, implement: + + Desktop app responsibilities: + * Generate a random challenge string (length `32–64`) + * Include it in `/alive` response + * When receiving `/exchange`, verify: + * The challenge was signed by your backend + * The signature or token is valid + * Only then return a desktop session token + + Requirements: + * Challenge must be valid only once + * Challenge must expire in `≤ 30` seconds + * Challenge tied to desktop session ID + +--- + +3. **Communication With Your Backend** + + The desktop app must: + * Use its existing authentication session (local token, refresh token, etc.) + * When receiving the signed challenge from web app: + 1. Send the challenge + desktop login token to backend + 2. Backend verifies that: + * Desktop user is authenticated + * Challenge matches web request + * Receive a web-session-token from backend (This is the token issued by `AuthController.LoginFromSession`) + * Return it to the web app via `/exchange` + +--- + +4. **Local HTTP Server CORS Headers** + + Your desktop server must include: + + `Access-Control-Allow-Origin: https://your-web-domain.com` + `Access-Control-Allow-Headers: *` + `Access-Control-Allow-Methods: GET, POST, OPTIONS` + + Also allow: + * Preflight `OPTIONS` requests + + This lets browser JavaScript call your localhost server directly. + +--- + +5. **Random Port Broadcasting** + + On startup, the desktop app picks a random port and exposes: + * `/alive` + * `/exchange` + + But the web app needs to know the port. + + Two solutions: + + Option A (simple): + + Web app scans 20 known ports (e.g., `41000–41020`). + + Option B (secure): + + Desktop app writes port to: + * macOS: `~/Library/Application Support/MyApp/port.json` + * Windows: `%APPDATA%/MyApp/port.json` + * Linux: `~/.config/MyApp/port.json` + + Web app then tries only one port if user clicks “Connect Desktop”. + +--- + +6. **Custom Protocol (Optional but helpful)** + + Register custom protocol: + + `solian://auth/connect` + + Used to trigger desktop app if it’s closed. + + Flow: + 1. Web app tries localhost discovery + 2. If not found → open `solian://auth/connect?some args` + 3. Desktop app starts → exposes localhost server + 4. Web page retries detection + +--- + +7. **App UI Behavior** + + The desktop UI should: + * Run the HTTP server silently in background + * Possibly show a “Connecting to web” indicator + * Close or hide handshake window after success + * Notify user if login sync succeeded + +--- + +8. **Logging** + + Implement basic logs: + * Server started on port `XXX` + * Received `/alive` + * Verified challenge + * Sent credentials to web + * Errors or invalid tokens + + Logs should never include full tokens. + +--- + +9. **Optional: WebSocket Support** + + Not required, but improves performance. + * Web app connects `ws://localhost:/ws` + * Faster challenge exchange + * Real-time two-way handshake + +--- + +## ⭐ Summary: Desktop App Needs to Implement + +Core +* Local HTTP server on `127.0.0.1:` +* `/alive` endpoint with random challenge +* `/exchange` endpoint to finish login +* CORS + preflight support +* Challenge-response security +* Only one-time challenges + +Backend communication +* Desktop app verifies web request with backend +* Backend creates session for web (via `AuthController.LoginFromSession`) +* Desktop returns session token to web + +Optional +* Custom URI protocol handler (`solian://`) +* Port broadcast file +* WebSocket tunnel