✨ Auth via authorized device
This commit is contained in:
@@ -275,6 +275,14 @@ public class AuthController(
|
|||||||
public string Token { get; set; } = string.Empty;
|
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")]
|
[HttpPost("token")]
|
||||||
public async Task<ActionResult<TokenExchangeResponse>> ExchangeToken([FromBody] TokenExchangeRequest request)
|
public async Task<ActionResult<TokenExchangeResponse>> ExchangeToken([FromBody] TokenExchangeRequest request)
|
||||||
{
|
{
|
||||||
@@ -325,4 +333,35 @@ public class AuthController(
|
|||||||
});
|
});
|
||||||
return Ok();
|
return Ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpPost("login/session")]
|
||||||
|
[Microsoft.AspNetCore.Authorization.Authorize] // Use full namespace to avoid ambiguity with DysonNetwork.Pass.Permission.Authorize
|
||||||
|
public async Task<ActionResult<TokenExchangeResponse>> 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 });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -664,4 +664,40 @@ public class AuthService(
|
|||||||
|
|
||||||
return Convert.FromBase64String(padded);
|
return Convert.FromBase64String(padded);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a new session derived from an existing parent session.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="parentSession">The existing session from which the new session is derived.</param>
|
||||||
|
/// <param name="deviceId">The ID of the device for the new session.</param>
|
||||||
|
/// <param name="deviceName">The name of the device for the new session.</param>
|
||||||
|
/// <param name="platform">The platform of the device for the new session.</param>
|
||||||
|
/// <param name="expiredAt">Optional: The expiration time for the new session.</param>
|
||||||
|
/// <returns>The newly created SnAuthSession.</returns>
|
||||||
|
public async Task<SnAuthSession> 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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
205
docs/WebLocalCredentialSharing.md
Normal file
205
docs/WebLocalCredentialSharing.md
Normal file
@@ -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": "<randomChallenge>" }`
|
||||||
|
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:<port>/ws`
|
||||||
|
* Faster challenge exchange
|
||||||
|
* Real-time two-way handshake
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⭐ Summary: Desktop App Needs to Implement
|
||||||
|
|
||||||
|
Core
|
||||||
|
* Local HTTP server on `127.0.0.1:<random_port>`
|
||||||
|
* `/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
|
||||||
Reference in New Issue
Block a user