Phase 1: User Authentication System - Added user-data.js: Secret code generation, user creation, session management - Added UserAuth.tsx: LoginGate, onboarding wizard, secret code reveal dialog - Users get isolated environments (projects, chats, API keys) Phase 2: Inline Qwen OAuth (No External CLI) - Added qwen-oauth.js: Device Authorization Grant with PKCE - Added QwenAuthDialog.tsx: Full inline auth flow with user code display - Tokens saved per-user with legacy fallback Phase 3: Integration - Updated main.js with IPC handlers for user auth and Qwen OAuth - Updated preload.js with electron.user and electron.qwenAuth bridges - Wrapped App.tsx with LoginGate for authentication enforcement Based on analysis of qwen-code repository OAuth implementation.
512 lines
17 KiB
Markdown
512 lines
17 KiB
Markdown
# Implementation Plan: Secret Key User System & Inline Qwen OAuth
|
|
|
|
## Overview
|
|
|
|
This plan outlines the implementation of:
|
|
1. **Secret Key User Authentication** - Users create accounts with a name + secret question, receive a unique key
|
|
2. **Isolated User Environments** - Each user has separate data (API keys, chats, sessions, projects)
|
|
3. **Inline Qwen OAuth** - Replace external CLI dependency with native device flow authentication
|
|
|
|
---
|
|
|
|
## Phase 1: User Identity & Secret Key System
|
|
|
|
### 1.1 Secret Code Generation
|
|
|
|
**Algorithm:**
|
|
```
|
|
SecretCode = Base64(SHA256(userName + secretQuestion + answer + timestamp + randomSalt))[:24]
|
|
```
|
|
|
|
Example output: `GU-AXBY12-CDWZ34-EFGH56`
|
|
|
|
**Security Properties:**
|
|
- One-way derivation (cannot reverse-engineer original answer)
|
|
- Time-salted to prevent duplicate codes
|
|
- 24-character code is memorable yet secure (144 bits of entropy)
|
|
|
|
### 1.2 User Data Model
|
|
|
|
```typescript
|
|
interface GooseUser {
|
|
userId: string; // UUID
|
|
displayName: string;
|
|
secretCodeHash: string; // SHA256 hash of the secret code (for verification)
|
|
createdAt: number;
|
|
lastLoginAt: number;
|
|
}
|
|
```
|
|
|
|
### 1.3 Files & Storage Structure
|
|
|
|
**Location:** `%AppData%/GooseUltra/` (Windows) or `~/.config/GooseUltra/` (Linux/Mac)
|
|
|
|
```
|
|
GooseUltra/
|
|
├── system/
|
|
│ ├── users.json # Array of GooseUser (stores hashes, not codes)
|
|
│ └── current_session.json # { userId, loginAt }
|
|
└── user_data/
|
|
└── {userId}/
|
|
├── settings.json # User-specific settings
|
|
├── qwen_tokens.json # User's Qwen OAuth credentials
|
|
├── ollama_key.enc # User's Ollama API key
|
|
├── projects/ # User's projects
|
|
├── chats/ # User's chat history
|
|
└── vault/ # User's credential vault
|
|
```
|
|
|
|
### 1.4 New Components
|
|
|
|
| Component | Location | Purpose |
|
|
|-----------|----------|---------|
|
|
| `LoginGate.tsx` | `src/components/` | Full-screen intro/login component |
|
|
| `UserOnboarding.tsx` | `src/components/` | Name + secret question wizard |
|
|
| `SecretCodeReveal.tsx` | `src/components/` | Shows code once with copy button |
|
|
| `UserContext.tsx` | `src/` | React context for current user |
|
|
|
|
### 1.5 Onboarding Flow
|
|
|
|
```
|
|
┌─────────────────────────────────────────┐
|
|
│ Welcome to Goose Ultra │
|
|
│ │
|
|
│ ○ I'm new here (Create Account) │
|
|
│ ○ I have a secret code (Login) │
|
|
└─────────────────────────────────────────┘
|
|
↓ "New User"
|
|
┌─────────────────────────────────────────┐
|
|
│ Step 1: What's your name? │
|
|
│ ┌─────────────────────────────────┐ │
|
|
│ │ [Your Display Name ] │
|
|
│ └─────────────────────────────────┘ │
|
|
└─────────────────────────────────────────┘
|
|
↓
|
|
┌─────────────────────────────────────────┐
|
|
│ Step 2: Set Your Secret Question │
|
|
│ │
|
|
│ Pick a question (dropdown): │
|
|
│ • Mother's maiden name? │
|
|
│ • First pet's name? │
|
|
│ • Favorite teacher's name? │
|
|
│ • City you were born in? │
|
|
│ • Your custom question... │
|
|
│ │
|
|
│ Your answer: [______________] │
|
|
└─────────────────────────────────────────┘
|
|
↓
|
|
┌─────────────────────────────────────────┐
|
|
│ 🎉 Your Secret Code is Ready! │
|
|
│ │
|
|
│ ┌──────────────────────────────────┐ │
|
|
│ │ GU-AXBY12-CDWZ34-EFGH56 │ │
|
|
│ └──────────────────────────────────┘ │
|
|
│ [📋 Copy to Clipboard] │
|
|
│ │
|
|
│ ⚠️ SAVE THIS CODE OFFLINE! │
|
|
│ This is the ONLY way to log in. │
|
|
│ We cannot recover it. │
|
|
│ │
|
|
│ [ ] I have saved my code securely │
|
|
│ │
|
|
│ [Continue to Goose Ultra →] │
|
|
└─────────────────────────────────────────┘
|
|
```
|
|
|
|
---
|
|
|
|
## Phase 2: User Data Isolation
|
|
|
|
### 2.1 Data Isolation Layer
|
|
|
|
**New Service:** `src/services/userDataService.ts`
|
|
|
|
```typescript
|
|
export class UserDataService {
|
|
private userId: string | null = null;
|
|
|
|
setCurrentUser(userId: string) { ... }
|
|
|
|
getUserDataPath(): string {
|
|
// Returns: userData/user_data/{userId}/
|
|
}
|
|
|
|
async loadUserSettings(): Promise<UserSettings> { ... }
|
|
async saveUserSettings(settings: UserSettings): Promise<void> { ... }
|
|
|
|
async loadQwenTokens(): Promise<QwenCredentials | null> { ... }
|
|
async saveQwenTokens(tokens: QwenCredentials): Promise<void> { ... }
|
|
|
|
async getProjectsPath(): string { ... }
|
|
async getChatsPath(): string { ... }
|
|
|
|
async cleanUserData(): Promise<void> {
|
|
// Wipes all user data (projects, chats, keys)
|
|
}
|
|
}
|
|
```
|
|
|
|
### 2.2 Logout & Clean Data
|
|
|
|
**Logout Flow:**
|
|
```
|
|
┌─────────────────────────────────────────┐
|
|
│ Logging Out... │
|
|
│ │
|
|
│ Would you like to clean your data? │
|
|
│ │
|
|
│ This will permanently delete: │
|
|
│ • All your projects │
|
|
│ • All chat history │
|
|
│ • Saved API keys │
|
|
│ • Custom personas │
|
|
│ │
|
|
│ Your account will remain intact. │
|
|
│ You can log in again with your code. │
|
|
│ │
|
|
│ [Keep Data & Logout] [Clean & Logout] │
|
|
└─────────────────────────────────────────┘
|
|
```
|
|
|
|
**"Clean Data" Explanation (to show users):**
|
|
|
|
> **What does "Clean Data" mean?**
|
|
>
|
|
> Cleaning your data removes all personal information from this device, including:
|
|
> - **Projects:** All HTML, CSS, and JavaScript you've created
|
|
> - **Chat History:** All conversations with the AI
|
|
> - **API Keys:** Any Qwen or Ollama credentials you've entered
|
|
> - **Personas:** Custom AI personalities you've configured
|
|
>
|
|
> **Why clean?**
|
|
> - You're using a shared or public computer
|
|
> - You want to free up disk space
|
|
> - You're troubleshooting issues
|
|
> - You want a fresh start
|
|
>
|
|
> **Note:** Your account code will still work. Cleaning only affects data on THIS device.
|
|
|
|
---
|
|
|
|
## Phase 3: Inline Qwen OAuth (No External CLI)
|
|
|
|
### 3.1 Current vs. New Architecture
|
|
|
|
**Current Flow (Requires External CLI):**
|
|
```
|
|
User clicks "Auth" → Electron opens external Qwen CLI → CLI does OAuth → Writes ~/.qwen/oauth_creds.json → Goose reads it
|
|
```
|
|
|
|
**New Flow (Fully Inline):**
|
|
```
|
|
User clicks "Auth" → Electron starts Device Flow → Opens browser for authorization → Polls for token → Saves per-user
|
|
```
|
|
|
|
### 3.2 New Electron Module: `qwen-oauth.js`
|
|
|
|
**Based on:** `qwen-code-reference/packages/core/src/qwen/qwenOAuth2.ts`
|
|
|
|
```javascript
|
|
// electron/qwen-oauth.js
|
|
|
|
const QWEN_OAUTH_DEVICE_CODE_ENDPOINT = 'https://chat.qwen.ai/api/v1/oauth2/device/code';
|
|
const QWEN_OAUTH_TOKEN_ENDPOINT = 'https://chat.qwen.ai/api/v1/oauth2/token';
|
|
const QWEN_OAUTH_CLIENT_ID = 'f0304373b74a44d2b584a3fb70ca9e56';
|
|
const QWEN_OAUTH_SCOPE = 'openid profile email model.completion';
|
|
const QWEN_OAUTH_GRANT_TYPE = 'urn:ietf:params:oauth:grant-type:device_code';
|
|
|
|
// PKCE Helpers
|
|
function generateCodeVerifier() { ... }
|
|
function generateCodeChallenge(verifier) { ... }
|
|
|
|
// Main OAuth Flow
|
|
export async function startDeviceFlow(onProgress, onSuccess, onError) {
|
|
// 1. Generate PKCE pair
|
|
const { code_verifier, code_challenge } = generatePKCEPair();
|
|
|
|
// 2. Request device code from Qwen
|
|
const deviceAuthResponse = await fetch(QWEN_OAUTH_DEVICE_CODE_ENDPOINT, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
body: new URLSearchParams({
|
|
client_id: QWEN_OAUTH_CLIENT_ID,
|
|
scope: QWEN_OAUTH_SCOPE,
|
|
code_challenge,
|
|
code_challenge_method: 'S256'
|
|
})
|
|
});
|
|
|
|
const { device_code, user_code, verification_uri_complete, expires_in } = await deviceAuthResponse.json();
|
|
|
|
// 3. Notify UI with authorization URL
|
|
onProgress({
|
|
status: 'awaiting_auth',
|
|
url: verification_uri_complete,
|
|
userCode: user_code,
|
|
expiresIn: expires_in
|
|
});
|
|
|
|
// 4. Open browser automatically
|
|
shell.openExternal(verification_uri_complete);
|
|
|
|
// 5. Poll for token
|
|
const pollInterval = 2000;
|
|
const maxAttempts = Math.ceil(expires_in / (pollInterval / 1000));
|
|
|
|
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
await sleep(pollInterval);
|
|
|
|
const tokenResponse = await fetch(QWEN_OAUTH_TOKEN_ENDPOINT, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
body: new URLSearchParams({
|
|
grant_type: QWEN_OAUTH_GRANT_TYPE,
|
|
client_id: QWEN_OAUTH_CLIENT_ID,
|
|
device_code,
|
|
code_verifier
|
|
})
|
|
});
|
|
|
|
const tokenData = await tokenResponse.json();
|
|
|
|
if (tokenData.access_token) {
|
|
// SUCCESS!
|
|
const credentials = {
|
|
access_token: tokenData.access_token,
|
|
refresh_token: tokenData.refresh_token,
|
|
token_type: tokenData.token_type,
|
|
resource_url: tokenData.resource_url,
|
|
expiry_date: Date.now() + (tokenData.expires_in * 1000)
|
|
};
|
|
onSuccess(credentials);
|
|
return;
|
|
}
|
|
|
|
if (tokenData.error === 'authorization_pending') {
|
|
onProgress({ status: 'polling', attempt, maxAttempts });
|
|
continue;
|
|
}
|
|
|
|
if (tokenData.error === 'slow_down') {
|
|
pollInterval = Math.min(pollInterval * 1.5, 10000);
|
|
continue;
|
|
}
|
|
|
|
// Other error
|
|
onError(tokenData.error_description || tokenData.error);
|
|
return;
|
|
}
|
|
|
|
onError('Authorization timed out');
|
|
}
|
|
|
|
export async function refreshAccessToken(refreshToken) { ... }
|
|
```
|
|
|
|
### 3.3 IPC Bridge Updates
|
|
|
|
**New handlers in `main.js`:**
|
|
|
|
```javascript
|
|
import * as qwenOAuth from './qwen-oauth.js';
|
|
|
|
// Start Device Authorization Flow
|
|
ipcMain.on('qwen-auth-start', async (event) => {
|
|
const window = BrowserWindow.fromWebContents(event.sender);
|
|
|
|
await qwenOAuth.startDeviceFlow(
|
|
(progress) => window.webContents.send('qwen-auth-progress', progress),
|
|
(credentials) => {
|
|
// Save to user-specific location
|
|
const userId = getCurrentUserId(); // From session
|
|
userDataService.saveQwenTokens(userId, credentials);
|
|
window.webContents.send('qwen-auth-success', credentials);
|
|
},
|
|
(error) => window.webContents.send('qwen-auth-error', error)
|
|
);
|
|
});
|
|
|
|
// Cancel ongoing auth
|
|
ipcMain.on('qwen-auth-cancel', () => {
|
|
qwenOAuth.cancelAuth();
|
|
});
|
|
```
|
|
|
|
### 3.4 Preload Updates
|
|
|
|
```javascript
|
|
// preload.js - add to existing
|
|
|
|
qwenAuth: {
|
|
start: () => ipcRenderer.send('qwen-auth-start'),
|
|
cancel: () => ipcRenderer.send('qwen-auth-cancel'),
|
|
onProgress: (cb) => ipcRenderer.on('qwen-auth-progress', (_, data) => cb(data)),
|
|
onSuccess: (cb) => ipcRenderer.on('qwen-auth-success', (_, creds) => cb(creds)),
|
|
onError: (cb) => ipcRenderer.on('qwen-auth-error', (_, err) => cb(err)),
|
|
}
|
|
```
|
|
|
|
### 3.5 UI Component: Inline Auth Dialog
|
|
|
|
```tsx
|
|
// src/components/QwenAuthDialog.tsx
|
|
|
|
export const QwenAuthDialog = ({ onComplete }: { onComplete: () => void }) => {
|
|
const [status, setStatus] = useState<'idle' | 'awaiting' | 'polling' | 'success' | 'error'>('idle');
|
|
const [authUrl, setAuthUrl] = useState('');
|
|
const [userCode, setUserCode] = useState('');
|
|
const [error, setError] = useState('');
|
|
|
|
useEffect(() => {
|
|
if (!window.electron?.qwenAuth) return;
|
|
|
|
window.electron.qwenAuth.onProgress((data) => {
|
|
if (data.status === 'awaiting_auth') {
|
|
setStatus('awaiting');
|
|
setAuthUrl(data.url);
|
|
setUserCode(data.userCode);
|
|
} else if (data.status === 'polling') {
|
|
setStatus('polling');
|
|
}
|
|
});
|
|
|
|
window.electron.qwenAuth.onSuccess(() => {
|
|
setStatus('success');
|
|
setTimeout(onComplete, 1500);
|
|
});
|
|
|
|
window.electron.qwenAuth.onError((err) => {
|
|
setStatus('error');
|
|
setError(err);
|
|
});
|
|
}, []);
|
|
|
|
const startAuth = () => {
|
|
setStatus('awaiting');
|
|
window.electron?.qwenAuth?.start();
|
|
};
|
|
|
|
return (
|
|
<div className="fixed inset-0 bg-black/80 flex items-center justify-center z-50">
|
|
<div className="bg-zinc-900 rounded-2xl p-8 max-w-md w-full border border-white/10">
|
|
{status === 'idle' && (
|
|
<>
|
|
<h2 className="text-2xl font-bold mb-4">Connect to Qwen</h2>
|
|
<p className="text-zinc-400 mb-6">
|
|
Authenticate with your Qwen account to access AI models.
|
|
</p>
|
|
<button onClick={startAuth} className="w-full py-3 bg-primary text-black font-bold rounded-xl">
|
|
Sign in with Qwen
|
|
</button>
|
|
</>
|
|
)}
|
|
|
|
{status === 'awaiting' && (
|
|
<>
|
|
<h2 className="text-2xl font-bold mb-4">Complete in Browser</h2>
|
|
<p className="text-zinc-400 mb-4">
|
|
A browser window should have opened. Enter this code:
|
|
</p>
|
|
<div className="bg-black p-4 rounded-xl text-center mb-4">
|
|
<span className="font-mono text-3xl text-primary">{userCode}</span>
|
|
</div>
|
|
<a href={authUrl} target="_blank" className="text-primary underline text-sm">
|
|
Click here if browser didn't open
|
|
</a>
|
|
</>
|
|
)}
|
|
|
|
{status === 'polling' && (
|
|
<>
|
|
<h2 className="text-2xl font-bold mb-4">Waiting for Authorization...</h2>
|
|
<div className="animate-spin w-8 h-8 border-2 border-primary border-t-transparent rounded-full mx-auto" />
|
|
</>
|
|
)}
|
|
|
|
{status === 'success' && (
|
|
<>
|
|
<h2 className="text-2xl font-bold text-primary mb-4">✓ Connected!</h2>
|
|
</>
|
|
)}
|
|
|
|
{status === 'error' && (
|
|
<>
|
|
<h2 className="text-2xl font-bold text-red-500 mb-4">Authentication Failed</h2>
|
|
<p className="text-zinc-400 mb-6">{error}</p>
|
|
<button onClick={startAuth} className="w-full py-3 bg-zinc-800 text-white font-bold rounded-xl">
|
|
Try Again
|
|
</button>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
```
|
|
|
|
---
|
|
|
|
## Phase 4: Implementation Order
|
|
|
|
### Step 1: Foundation (Electron Main)
|
|
1. Create `userDataService.js` in `electron/`
|
|
2. Create `qwen-oauth.js` in `electron/`
|
|
3. Update `main.js` with new IPC handlers
|
|
4. Update `preload.js` with new bridges
|
|
|
|
### Step 2: User System (React)
|
|
1. Create `UserContext.tsx`
|
|
2. Create `LoginGate.tsx`
|
|
3. Create `UserOnboarding.tsx`
|
|
4. Create `SecretCodeReveal.tsx`
|
|
5. Wrap `App.tsx` with `LoginGate`
|
|
|
|
### Step 3: Data Migration
|
|
1. Migrate existing global data to first user
|
|
2. Update all file paths in services to use `userDataService`
|
|
|
|
### Step 4: Qwen OAuth UI
|
|
1. Create `QwenAuthDialog.tsx`
|
|
2. Update `AISettingsModal` to use inline auth
|
|
3. Remove references to external CLI
|
|
|
|
### Step 5: Logout & Cleanup
|
|
1. Add logout button to sidebar
|
|
2. Create cleanup dialog with explanation
|
|
3. Implement `cleanUserData()` function
|
|
|
|
---
|
|
|
|
## Critical Files to Modify
|
|
|
|
| File | Changes |
|
|
|------|---------|
|
|
| `electron/main.js` | Add user session management, new IPC handlers |
|
|
| `electron/preload.js` | Expose user and auth bridges |
|
|
| `electron/qwen-api.js` | Load tokens from user-specific path |
|
|
| `src/App.tsx` | Wrap with LoginGate and UserContext |
|
|
| `src/orchestrator.ts` | Make project loading user-aware |
|
|
| `src/services/automationService.ts` | Update file paths |
|
|
| `src/components/LayoutComponents.tsx` | Add logout button, update auth UI |
|
|
|
|
---
|
|
|
|
## Security Considerations
|
|
|
|
1. **Secret Code Storage**: Only SHA256 hash is stored; actual code never persisted
|
|
2. **Credential Isolation**: Each user's Qwen/Ollama tokens are in separate directories
|
|
3. **Clean Data**: Complete wipe of user-specific folder
|
|
4. **No Recovery**: By design, secret codes cannot be recovered (offline storage is essential)
|
|
|
|
---
|
|
|
|
## Estimated Effort
|
|
|
|
| Phase | Effort |
|
|
|-------|--------|
|
|
| Phase 1: User Identity | 4-6 hours |
|
|
| Phase 2: Data Isolation | 3-4 hours |
|
|
| Phase 3: Inline OAuth | 4-5 hours |
|
|
| Phase 4: Integration | 2-3 hours |
|
|
| **Total** | **13-18 hours** |
|