ReadKeep/docs/OAuth2-Implementation-Plan.md
Ilyas Hallak ec432a037c feat: Implement OAuth 2.0 authentication with PKCE and automatic fallback
Add complete OAuth 2.0 Authorization Code Flow with PKCE as alternative
to API token authentication, with automatic server detection and graceful
fallback to classic login.

**OAuth Core (RFC 7636 PKCE):**
- PKCEGenerator: S256 challenge generation for secure code exchange
- OAuth DTOs: Client registration, token request/response models
- OAuthClient, OAuthToken, AuthenticationMethod domain models
- API.swift: registerOAuthClient() and exchangeOAuthToken() endpoints
- OAuthRepository + POAuthRepository protocol

**Browser Integration (ASWebAuthenticationSession):**
- OAuthSession: Wraps native authentication session
- OAuthFlowCoordinator: Orchestrates 5-phase OAuth flow
- readeck:// URL scheme for OAuth callback handling
- State verification for CSRF protection
- User cancellation handling

**Token Management:**
- KeychainHelper: OAuth token storage alongside API tokens
- TokenProvider: getOAuthToken(), setOAuthToken(), getAuthMethod()
- AuthenticationMethod enum to distinguish token types
- AuthRepository: loginWithOAuth(), getAuthenticationMethod()
- Endpoint persistence in both Keychain and Settings

**Server Feature Detection:**
- ServerInfo extended with features array and supportsOAuth flag
- GET /api/info endpoint integration (backward compatible)
- GetServerInfoUseCase with optional endpoint parameter

**User Profile Integration:**
- ProfileApiClient: Fetch user data via GET /api/profile
- UserProfileDto with username, email, provider information
- GetUserProfileUseCase: Extract username from profile
- Username saved and displayed for OAuth users (like classic auth)

**Automatic OAuth Flow (No User Selection):**
- OnboardingServerView: 2-phase flow (endpoint → auto-OAuth or classic)
- OAuth attempted automatically if server supports it
- Fallback to username/password on OAuth failure or unsupported
- SettingsServerViewModel: checkServerOAuthSupport(), loginWithOAuth()

**Cleanup & Refactoring:**
- Remove all #if os(iOS) && !APP_EXTENSION conditionals
- Remove LoginMethodSelectionView (no longer needed)
- Remove switchToClassicLogin() method
- Factories updated with OAuth dependencies

**Testing:**
- PKCEGeneratorTests: Verify RFC 7636 compliance
- ServerInfoTests: Feature detection and backward compatibility
- Mock implementations for all OAuth components

**Documentation:**
- docs/OAuth2-Implementation-Plan.md: Complete implementation guide
- openapi.json: Readeck API specification

**Scopes Requested:**
- bookmarks:read, bookmarks:write, profile:read

OAuth users now have full feature parity with classic authentication.
Server auto-detects OAuth support via /info endpoint. Seamless UX with
browser-based login and automatic fallback.
2025-12-19 21:56:40 +01:00

471 lines
15 KiB
Markdown

# OAuth 2.0 Authentication - Implementation Plan
**Date:** December 19, 2025
**Status:** In Progress (Phase 1-4 Complete ✅)
**Goal:** Add OAuth 2.0 authentication support as an alternative to API token authentication
---
## 📋 Overview
### Current Situation (Updated)
-**Phase 1-4 Complete:** ServerInfo extended, OAuth core implemented, browser integration done, token management working
-**Direct OAuth Flow:** No login method selection - OAuth is tried automatically if server supports it
-**Fallback to Classic:** If OAuth fails or is not supported, classic username/password login is shown
-**Endpoint Storage:** Endpoint is now saved in both TokenProvider and Settings
-**API Integration:** API calls don't yet use OAuth tokens properly
-**Testing:** Manual and integration testing not complete
### What's Implemented ✅
**Phase 1: ServerInfo Extended**
-`ServerInfoDto` with nested `VersionInfo` struct (canonical, release, build)
-`ServerInfo` model with `features: [String]?` and `supportsOAuth` computed property
-`GetServerInfoUseCase` with optional endpoint parameter
-`InfoApiClient` with endpoint parameter logic (custom endpoint vs TokenProvider)
- ✅ Unit tests for ServerInfo and feature detection
- ✅ Backward compatibility with old servers
**Phase 2: OAuth Core**
-`PKCEGenerator` with verifier/challenge generation
- ✅ OAuth DTOs: `OAuthClientCreateDto`, `OAuthClientResponseDto`, `OAuthTokenRequestDto`, `OAuthTokenResponseDto`
- ✅ Domain models: `OAuthClient`, `OAuthToken`, `AuthenticationMethod`
-`API.swift` extended with OAuth methods (`registerOAuthClient`, `exchangeOAuthToken`)
-`OAuthRepository` + `POAuthRepository` protocol
-`OAuthManager` orchestrates OAuth flow
- ✅ Unit tests for PKCEGenerator
**Phase 3: Browser Integration**
-`OAuthSession` wraps `ASWebAuthenticationSession`
-`OAuthFlowCoordinator` manages 5-phase OAuth flow
-`readeck://` URL scheme registered in Info.plist
- ✅ State verification for CSRF protection
- ✅ Error handling for user cancellation
**Phase 4: Token Management**
-`AuthenticationMethod` enum (apiToken, oauth)
-`TokenProvider` extended with OAuth methods (`getOAuthToken`, `setOAuthToken`, `getAuthMethod`, `setAuthMethod`)
-`setEndpoint(_ endpoint: String)` added to TokenProvider
-`KeychainHelper` extended with OAuth token storage
-`KeychainTokenProvider` handles both token types
-`AuthRepository` extended with `loginWithOAuth`, `getAuthenticationMethod`, `switchToClassicAuth`
- ✅ Endpoint now saved in TokenProvider (was missing before)
**Phase 5: UI & UX (Partially Complete)**
-`OnboardingServerView` refactored with 2-phase flow:
- Phase 1: Only endpoint field + Readeck logo
- Phase 2a: OAuth attempted automatically if server supports it
- Phase 2b: Username/Password fields shown if OAuth not supported or fails
-`SettingsServerViewModel` extended with `checkServerOAuthSupport()` and `loginWithOAuth()`
-`LoginWithOAuthUseCase` created
- ✅ Factories updated (DefaultUseCaseFactory, MockUseCaseFactory)
- ❌ LoginMethodSelectionView removed (no longer needed - OAuth is automatic)
**Removed/Changed:**
- ❌ All `#if os(iOS) && !APP_EXTENSION` checks removed
-`LoginMethodSelectionView` removed - OAuth is now attempted automatically
-`showLoginMethodSelection` flag removed
- ✅ Direct OAuth attempt with fallback to classic login on error
---
## 🎯 Updated Goals
1. ~~**User Choice**~~ **Automatic OAuth**: OAuth is attempted automatically if server supports it
2. **Auto-Detection**: ✅ Automatically detect if server supports OAuth via `/info` endpoint
3. **Security**: ✅ OAuth 2.0 Authorization Code Flow with PKCE (S256)
4. **UX**: ✅ Seamless browser-based authentication using `ASWebAuthenticationSession`
5. **Fallback**: ✅ Graceful fallback to username/password if OAuth fails or is not supported
6. **Migration Path**: ❌ Not yet implemented for existing users
---
## 🔍 API Analysis
### Server Info Response (Actual Format)
```json
{
"version": {
"canonical": "0.21.4",
"release": "0.21.4",
"build": ""
},
"features": ["oauth"]
}
```
**Changes from Original Plan:**
- `version` is now an object, not a string
- `buildDate` and `userAgent` removed (not in actual response)
- `features` is optional (only in newer servers)
### OAuth Endpoints
1. **Feature Detection**: `GET /api/info` (no auth)
2. **Client Registration**: `POST /api/oauth/client` (no auth)
3. **Authorization**: `GET /authorize` (browser, web page)
4. **Token Exchange**: `POST /api/oauth/token` (application/x-www-form-urlencoded)
---
## 🏗️ Architecture (Updated)
### Data Flow
```
User Input → OnboardingServerView
SettingsServerViewModel.checkServerOAuthSupport()
GetServerInfoUseCase.execute(endpoint: normalizedEndpoint)
ServerInfoRepository.getServerInfo(endpoint: normalizedEndpoint)
InfoApiClient.getServerInfo(endpoint: normalizedEndpoint)
GET {endpoint}/api/info (no auth token)
Response: { version: {...}, features: ["oauth"] }
serverSupportsOAuth = true
SettingsServerViewModel.loginWithOAuth()
LoginWithOAuthUseCase.execute(endpoint)
OAuthFlowCoordinator.executeOAuthFlow(endpoint)
[OAuth flow: client registration → browser → token exchange]
AuthRepository.loginWithOAuth(endpoint, token)
TokenProvider saves: OAuthToken, AuthMethod, Endpoint
User logged in ✅
```
### Key Components
**Domain Layer:**
- `ServerInfo` (version, features, supportsOAuth)
- `OAuthClient` (clientId, clientSecret, redirectUris, etc.)
- `OAuthToken` (accessToken, tokenType, scope, expiresIn, refreshToken, createdAt)
- `AuthenticationMethod` enum (apiToken, oauth)
**Data Layer:**
- `InfoApiClient` - handles `/api/info` with optional endpoint parameter
- `API` - extended with OAuth client registration and token exchange
- `OAuthRepository` - orchestrates OAuth API calls
- `OAuthManager` - business logic for OAuth flow
- `OAuthSession` - wraps ASWebAuthenticationSession
- `OAuthFlowCoordinator` - coordinates complete 5-phase flow
**Use Cases:**
- `GetServerInfoUseCase(endpoint: String?)` - get server info with optional custom endpoint
- `LoginWithOAuthUseCase` - execute OAuth login flow
- `CheckServerReachabilityUseCase` - check if server is reachable (existing)
**UI Layer:**
- `OnboardingServerView` - 2-phase onboarding (endpoint → OAuth or classic)
- `SettingsServerViewModel` - extended with OAuth support
---
## 🎨 User Experience Flow (Updated)
### New User Onboarding
```
1. User opens app
2. Screen shows:
- Readeck logo (green circle)
- "Enter your Readeck server"
- Endpoint text field
- Chips: http://, https://, 192.168., :8000
- "Continue" button
3. User enters endpoint (e.g., https://readeck.example.com)
4. User taps "Continue"
5. App normalizes endpoint and calls /api/info
6a. If OAuth supported:
App automatically starts OAuth flow
Browser opens with /authorize page
User logs in with username/password on server
User approves permissions
Redirect to readeck://oauth-callback?code=...
App exchanges code for token
Token + endpoint saved
User logged in ✅
6b. If OAuth NOT supported OR OAuth fails:
Username and Password fields appear
Text changes to "Enter your credentials"
Button changes to "Login & Save"
User enters credentials
Classic API token login
User logged in ✅
```
**Key UX Changes:**
- No login method selection screen (removed)
- OAuth is attempted automatically
- Fallback to classic is seamless
- User sees endpoint field first, login fields only if needed
---
## 🚀 Implementation Status
### ✅ Phase 1: ServerInfo Extended (COMPLETE)
-`ServerInfoDto` with `VersionInfo` nested struct
-`ServerInfo.features` optional array
-`supportsOAuth` computed property
-`GetServerInfoUseCase` with endpoint parameter
- ✅ Backward compatibility tests
- ✅ Mock implementations updated
### ✅ Phase 2: OAuth Core (COMPLETE)
- ✅ PKCEGenerator (verifier + challenge)
- ✅ All OAuth DTOs created
- ✅ Domain models created
- ✅ API methods implemented
- ✅ OAuthRepository created
- ✅ OAuthManager created
- ✅ Unit tests for PKCE
### ✅ Phase 3: Browser Integration (COMPLETE)
- ✅ OAuthSession wraps ASWebAuthenticationSession
- ✅ OAuthFlowCoordinator manages complete flow
- ✅ URL scheme registered (readeck://)
- ✅ State verification
- ✅ Error handling
### ✅ Phase 4: Token Management (COMPLETE)
- ✅ AuthenticationMethod enum
- ✅ TokenProvider extended with OAuth methods
-`setEndpoint()` added to TokenProvider
- ✅ KeychainHelper extended
- ✅ Token type detection in getToken()
- ✅ AuthRepository extended
- ✅ Endpoint saved in both TokenProvider and Settings
### 🟡 Phase 5: Use Cases & ViewModels (MOSTLY COMPLETE)
- ✅ LoginWithOAuthUseCase created
- ✅ AuthRepository extended
- ✅ SettingsServerViewModel extended
- ✅ Factories updated
- ❌ LoginMethodSelectionView removed (design change)
- ❌ Integration tests not complete
### 🟡 Phase 6: UI Implementation (MOSTLY COMPLETE)
- ✅ OnboardingServerView refactored (2-phase flow)
- ✅ Readeck logo with green background
- ✅ Dynamic text based on phase
- ✅ Conditional login fields
- ✅ OAuth auto-attempt logic
- ❌ Settings migration UI not implemented
- ❌ OAuth token info display not implemented
### ❌ Phase 7: Testing & Polish (NOT STARTED)
- ❌ Manual testing checklist
- ❌ Integration tests
- ❌ Performance testing
- ❌ Code review
### ❌ Phase 8: Migration & Documentation (NOT STARTED)
- ❌ Migration flow for existing users
- ❌ Release notes
- ❌ Documentation
---
## 🐛 Known Issues & TODOs
### Critical Issues
1. **API Calls Don't Use OAuth Token Properly**
- Problem: When using OAuth, API calls may not be sending the Bearer token correctly
- The `TokenProvider.getToken()` checks auth method and returns OAuth access token
- But need to verify API calls actually use it in Authorization header
- Check if API.swift properly uses `await tokenProvider.getToken()` for all authenticated requests
2. **Endpoint Parameter Flow Needs Verification**
- `GetServerInfoUseCase.execute(endpoint:)` receives endpoint parameter
- `ServerInfoRepository.getServerInfo(endpoint:)` passes it through
- `InfoApiClient.getServerInfo(endpoint:)` uses it OR falls back to TokenProvider
- Need to verify this chain works correctly
### Minor Issues
3. **No Migration UI for Existing Users**
- Users with API tokens can't migrate to OAuth yet
- Settings screen needs "Switch to OAuth" button
4. **No OAuth Token Display**
- Can't see which auth method is active
- Can't see token expiry or scopes
5. **All `#if` Checks Removed**
- Removed all `#if os(iOS) && !APP_EXTENSION` conditionals
- May cause issues with URLShare extension (needs verification)
---
## ✅ Updated TODO List
### 🔴 IMMEDIATE (Critical for functionality)
- [ ] **Verify API Integration with OAuth Token**
- [ ] Check that `API.swift` uses `await tokenProvider.getToken()` in all authenticated requests
- [ ] Verify Bearer token is sent correctly in Authorization header
- [ ] Test actual API call (e.g., fetch bookmarks) with OAuth token
- [ ] Add logging to confirm which token type is being used
- [ ] **Test Complete OAuth Flow End-to-End**
- [ ] Fresh install → OAuth server → OAuth login → fetch data
- [ ] Fresh install → non-OAuth server → classic login → fetch data
- [ ] OAuth login failure → fallback to classic login
- [ ] Verify endpoint is saved correctly in TokenProvider
### 🟡 HIGH PRIORITY (Important for UX)
- [ ] **Settings Screen Migration**
- [ ] Show current authentication method (OAuth vs API Token)
- [ ] Add "Switch to OAuth" button (if server supports it and user has API token)
- [ ] Add "Switch to Classic Login" option
- [ ] Show OAuth token info (scopes, expiry if available)
- [ ] **Error Handling & Messages**
- [ ] Better error messages for OAuth failures
- [ ] Network error handling
- [ ] Token expiry handling
- [ ] Server not reachable scenarios
### 🟢 MEDIUM PRIORITY (Nice to have)
- [ ] **Testing**
- [ ] Manual testing on real device
- [ ] Integration tests for OAuth flow
- [ ] Test on different iOS versions
- [ ] Test with different server versions
- [ ] **Documentation**
- [ ] Update README with OAuth instructions
- [ ] Add inline code documentation
- [ ] Create user guide for OAuth setup
### ⚪ LOW PRIORITY (Future enhancements)
- [ ] Token refresh support (if server implements it)
- [ ] Revoke token on logout
- [ ] Multiple account support
- [ ] Biometric re-authentication
---
## 📚 Implementation Notes
### Design Decisions
1. **No Login Method Selection Screen**
- Original plan had LoginMethodSelectionView
- Removed in favor of automatic OAuth attempt
- Rationale: Better UX, fewer clicks, OAuth is preferred
- Fallback to classic login happens automatically on error
2. **Endpoint in TokenProvider**
- Endpoint now saved in BOTH TokenProvider (Keychain) and Settings
- Rationale: InfoApiClient needs endpoint from TokenProvider for /api/info calls
- Ensures endpoint is available for all API calls
3. **Removed All `#if os(iOS) && !APP_EXTENSION`**
- Original code had many conditional compilation checks
- All removed for cleaner code
- May need to revisit if URLShare extension has issues
4. **ServerInfoDto Structure Changed**
- Original plan had flat version string
- Actual API returns nested VersionInfo object
- Adapted to match real API response
### Code Patterns
**Endpoint Resolution:**
```swift
// InfoApiClient
func getServerInfo(endpoint: String? = nil) async throws -> ServerInfoDto {
let baseEndpoint = try await resolveEndpoint(endpoint)
// Uses provided endpoint OR falls back to tokenProvider.getEndpoint()
}
```
**Token Type Detection:**
```swift
// KeychainTokenProvider
func getToken() async -> String? {
if let method = await getAuthMethod(), method == .oauth {
return await getOAuthToken()?.accessToken
}
return keychainHelper.loadToken() // API token
}
```
**OAuth Flow:**
```swift
// 5-phase flow in OAuthFlowCoordinator
1. Register client + generate PKCE
2. Build authorization URL
3. Open browser (ASWebAuthenticationSession)
4. Parse callback URL
5. Exchange code for token
```
---
## 🎯 Next Steps
### For Next Development Session
1. **Verify API Integration** (Most Critical)
- Add logging to see which token is being used
- Test actual API call with OAuth token
- Confirm Bearer token in Authorization header
2. **Manual Testing**
- Test OAuth flow end-to-end
- Test fallback to classic login
- Test on real device
3. **Settings Screen**
- Show current auth method
- Add migration button
4. **Error Handling**
- Better error messages
- Handle all failure scenarios
---
**Last Updated:** December 19, 2025
**Status:** Phase 1-4 Complete, Phase 5-6 Mostly Complete, Testing Needed