Issue #48: feat: Implement strong authentication for voting (itsme, eID)
- State:
OPEN
- Milestone:
Jalon 1: Sécurité & GDPR 🔒
- Labels:
phase:k8s,track:software priority:low,proptech:blockchain
- Assignees:
Unassigned
- Created:
2025-10-27
- Updated:
2025-11-13
- URL:
Description
## Context
**Belgian legal requirement:**
General assembly votes require **positive identification** of voters to ensure:
- Only legitimate co-owners vote
- One person = one owner (no impersonation)
- Non-repudiation (votes cannot be contested)
- Audit trail for legal disputes
**Current authentication:** ⚠️ Basic JWT (email + password)
- Sufficient for read operations
- **Insufficient for legally binding votes** (AG resolutions)
- No identity verification
- Risk of fraud/impersonation
## Legal Framework (Belgium)
**Code civil (Copropriété):**
- AG votes are legally binding
- Requires proof of identity for contested votes
- Minutes (procès-verbaux) must be legally defensible
**eIDAS Regulation (EU):**
- Electronic identification for cross-border services
- Levels: Low, Substantial, **High** (required for voting)
**Acceptable Belgian authentication methods:**
1. **itsme®** - Most popular (5M+ users in Belgium)
2. **Belgian eID** (electronic identity card + card reader)
3. **Qualified electronic signature** (eIDAS compliant)
## Objective
Implement **strong authentication** specifically for voting operations:
- itsme® integration (primary)
- Belgian eID support (secondary)
- Step-up authentication (elevate from JWT to strong auth when voting)
- Audit trail for authenticated votes
## Proposed Architecture
### Two-Tier Authentication
**Tier 1 - Standard (JWT):**
- Email + password
- Used for: browsing, viewing data, non-critical actions
- Current implementation ✅
**Tier 2 - Strong Authentication:**
- itsme® or eID
- **Required for:** casting votes, signing documents
- New implementation ❌
### Step-Up Authentication Flow
```
User logged in (JWT) → Navigates to voting page
↓
Clicks "Vote" button
↓
System detects: requires strong auth
↓
Redirect to itsme® authentication
↓
User authenticates via itsme® app
↓
itsme® returns identity + verification level
↓
System validates identity matches owner record
↓
Vote is cast with strong auth token
↓
Audit log records: owner_id, itsme® transaction_id
```
## itsme® Integration
### 1. itsme® Overview
**What is itsme®?**
- Belgian/EU digital identity app
- 5+ million active users in Belgium
- Used by banks, government, utilities
- Mobile app-based (QR code or deeplink)
- eIDAS "High" level assurance
**Authentication flow:**
1. User scans QR code or clicks deeplink
2. Opens itsme® app on phone
3. Authenticates with PIN/biometric
4. itsme® returns verified identity (name, national register number)
### 2. itsme® OpenID Connect (OIDC) Integration
**Provider:** itsme® acts as OpenID Connect Identity Provider
**Registration:**
- Create account at https://portal.itsme.be/
- Obtain Client ID & Client Secret
- Configure redirect URIs
**Scopes requested:**
```
openid profile email
service:koprogo_voting (custom service code)
```
**Claims returned:**
```json
{
"sub": "itsme_user_id",
"name": "John Doe",
"given_name": "John",
"family_name": "Doe",
"birthdate": "1980-01-01",
"national_register_number": "80010112345", // Belgian NISS
"email": "john@example.com",
"phone_number": "+32470123456",
"verified": true,
"ial": "http://itsme.services/IAL/HIGH" // Identity Assurance Level
}
```
### 3. Backend Implementation
**New entity:** `StrongAuthSession`
```rust
// backend/src/domain/entities/strong_auth_session.rs
pub struct StrongAuthSession {
pub id: Uuid,
pub user_id: Uuid,
pub owner_id: Uuid,
pub auth_provider: AuthProvider, // Itsme, Eid
pub provider_transaction_id: String, // itsme transaction ID
pub national_register_number: String, // NISS (encrypted)
pub identity_assurance_level: String, // "HIGH"
pub authenticated_at: DateTime<Utc>,
pub expires_at: DateTime<Utc>, // 15 minutes
pub used_for: String, // "vote:resolution_id"
}
pub enum AuthProvider {
Itsme,
BelgianEid,
}
```
**Database table:**
```sql
CREATE TABLE strong_auth_sessions (
id UUID PRIMARY KEY,
user_id UUID NOT NULL REFERENCES users(id),
owner_id UUID NOT NULL REFERENCES owners(id),
auth_provider VARCHAR(50) NOT NULL,
provider_transaction_id VARCHAR(255) NOT NULL,
national_register_number_encrypted TEXT NOT NULL, -- Encrypted NISS
identity_assurance_level VARCHAR(50) NOT NULL,
authenticated_at TIMESTAMP NOT NULL,
expires_at TIMESTAMP NOT NULL,
used_for VARCHAR(255) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_strong_auth_user ON strong_auth_sessions(user_id);
CREATE INDEX idx_strong_auth_expires ON strong_auth_sessions(expires_at);
```
**itsme® OIDC flow:**
```rust
// backend/src/infrastructure/auth/itsme_provider.rs
use openidconnect::{
AuthenticationFlow, AuthorizationCode, ClientId, ClientSecret,
CsrfToken, IssuerUrl, Nonce, RedirectUrl, Scope,
};
pub struct ItsmeProvider {
client: CoreClient,
}
impl ItsmeProvider {
pub fn new() -> Self {
let issuer = IssuerUrl::new("https://idp.e2e.itsme.services/v2".to_string()).unwrap();
let client_id = ClientId::new(env::var("ITSME_CLIENT_ID").unwrap());
let client_secret = ClientSecret::new(env::var("ITSME_CLIENT_SECRET").unwrap());
let redirect_url = RedirectUrl::new(env::var("ITSME_REDIRECT_URI").unwrap()).unwrap();
let client = CoreClient::new(client_id, Some(client_secret), issuer, redirect_url);
Self { client }
}
// Generate authorization URL (redirect user to itsme®)
pub fn get_authorization_url(&self, state: String) -> (String, CsrfToken, Nonce) {
let (auth_url, csrf_token, nonce) = self.client
.authorize_url(
AuthenticationFlow::<CoreResponseType>::AuthorizationCode,
CsrfToken::new,
Nonce::new,
)
.add_scope(Scope::new("openid".to_string()))
.add_scope(Scope::new("profile".to_string()))
.add_scope(Scope::new("service:koprogo_voting".to_string()))
.set_state(CsrfToken::new(state))
.url();
(auth_url.to_string(), csrf_token, nonce)
}
// Exchange authorization code for tokens
pub async fn exchange_code(&self, code: AuthorizationCode) -> Result<TokenResponse, String> {
self.client
.exchange_code(code)
.request_async(async_http_client)
.await
.map_err(|e| e.to_string())
}
// Verify ID token and extract claims
pub async fn verify_identity(&self, id_token: &str) -> Result<ItsmeIdentity, String> {
// Verify JWT signature, expiration, issuer
// Extract claims
// Validate IAL = HIGH
Ok(ItsmeIdentity {
sub: "...".to_string(),
name: "John Doe".to_string(),
national_register_number: "80010112345".to_string(),
identity_assurance_level: "HIGH".to_string(),
})
}
}
```
### 4. API Endpoints
**Initiate strong authentication:**
```
GET /api/v1/auth/strong/initiate?purpose=vote&resolution_id=<uuid>
→ Returns: { "auth_url": "https://idp.itsme.services/...", "state": "csrf_token" }
```
**Callback from itsme®:**
```
GET /api/v1/auth/strong/callback?code=<auth_code>&state=<csrf_token>
→ Validates code, creates StrongAuthSession, returns strong_auth_token
```
**Verify strong auth (middleware):**
```rust
#[derive(Debug)]
pub struct StrongAuth {
pub session: StrongAuthSession,
}
impl FromRequest for StrongAuth {
async fn from_request(req: &HttpRequest, _: &mut Payload) -> Result<Self, Error> {
let strong_auth_token = req
.headers()
.get("X-Strong-Auth-Token")
.and_then(|h| h.to_str().ok())
.ok_or(ErrorUnauthorized("Strong auth required"))?;
// Validate token, check expiration
let session = validate_strong_auth_token(strong_auth_token).await?;
Ok(StrongAuth { session })
}
}
```
**Protected vote endpoint:**
```rust
#[post("/resolutions/{id}/vote")]
async fn cast_vote(
resolution_id: web::Path<Uuid>,
vote_data: web::Json<VoteRequest>,
_strong_auth: StrongAuth, // Requires strong auth
) -> Result<HttpResponse, Error> {
// Cast vote with strong auth session logged
Ok(HttpResponse::Ok().json(vote))
}
```
### 5. Frontend Integration
**Strong auth button:**
```svelte
<script lang="ts">
export let resolutionId: string;
async function initiateStrongAuth() {
const response = await fetch(`/api/v1/auth/strong/initiate?purpose=vote&resolution_id=${resolutionId}`);
const { auth_url, state } = await response.json();
// Store state in sessionStorage
sessionStorage.setItem('strong_auth_state', state);
// Redirect to itsme®
window.location.href = auth_url;
}
async function handleCallback() {
const urlParams = new URLSearchParams(window.location.search);
const code = urlParams.get('code');
const state = urlParams.get('state');
// Validate state (CSRF protection)
if (state !== sessionStorage.getItem('strong_auth_state')) {
throw new Error('Invalid state');
}
// Exchange code for strong auth token
const response = await fetch(`/api/v1/auth/strong/callback?code=${code}&state=${state}`);
const { strong_auth_token } = await response.json();
// Store token (short-lived, 15 min)
sessionStorage.setItem('strong_auth_token', strong_auth_token);
// Redirect back to voting page
window.location.href = '/meetings/' + resolutionId + '/vote';
}
</script>
<button on:click={initiateStrongAuth} class="itsme-button">
<img src="/itsme-logo.svg" alt="itsme" />
Authentifier avec itsme®
</button>
```
**Vote with strong auth:**
```svelte
async function castVote(resolutionId: string, choice: VoteChoice) {
const strongAuthToken = sessionStorage.getItem('strong_auth_token');
const response = await fetch(`/api/v1/resolutions/${resolutionId}/vote`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}`, // Regular JWT
'X-Strong-Auth-Token': strongAuthToken, // Strong auth token
'Content-Type': 'application/json',
},
body: JSON.stringify({ vote_choice: choice }),
});
if (response.ok) {
showToast('Vote enregistré avec authentification forte');
} else {
showToast('Authentification forte expirée, veuillez vous réauthentifier');
}
}
```
## Belgian eID Support (Secondary)
**Alternative for users without itsme®:**
**Flow:**
1. User connects eID card reader to computer
2. Backend calls Belgian eID middleware (via PKCS#11 or Web eID)
3. User enters PIN on card reader
4. Backend validates certificate chain
5. Extract national register number from eID certificate
**Implementation complexity:** Higher (requires middleware installation)
**Recommendation:** Phase 2 (after itsme® working)
## Security Considerations
1. **National Register Number encryption:**
- Encrypt NISS at rest (AES-256)
- Never log NISS in plain text
- Access only for audit/dispute resolution
2. **Token expiration:**
- Strong auth token valid **15 minutes only**
- Cannot be reused for different resolutions
- Revoked after vote cast
3. **CSRF protection:**
- State parameter validated
- Nonce validated in ID token
4. **Audit trail:**
- Log all strong auth attempts (success/failure)
- Link votes to itsme® transaction IDs
- Immutable audit log
5. **Privacy (GDPR):**
- Minimal data collection (only required claims)
- Delete strong auth sessions after 90 days (audit retention)
- User consent for itsme® authentication
## Testing
- [ ] itsme® sandbox environment working
- [ ] Authorization flow completes successfully
- [ ] ID token validated correctly
- [ ] Identity matches owner record (NISS comparison)
- [ ] Strong auth token expires after 15 min
- [ ] Vote endpoint rejects without strong auth
- [ ] Vote endpoint accepts with valid strong auth
- [ ] Audit log records itsme® transaction ID
## Acceptance Criteria
- [ ] itsme® OIDC integration complete
- [ ] StrongAuthSession entity implemented
- [ ] Database schema with encrypted NISS storage
- [ ] API endpoints (initiate, callback) functional
- [ ] Strong auth middleware working
- [ ] Vote endpoint protected by strong auth
- [ ] Frontend itsme® button + callback handling
- [ ] Audit trail complete
- [ ] GDPR compliant (minimal data, consent)
- [ ] itsme® logo usage approved (branding guidelines)
- [ ] Legal review passed (if applicable)
## Dependencies
**Backend:**
```toml
[dependencies]
openidconnect = "3.5"
jsonwebtoken = "9.2"
ring = "0.17" # For encryption
```
**Environment variables:**
```bash
ITSME_CLIENT_ID=<client_id>
ITSME_CLIENT_SECRET=<client_secret>
ITSME_REDIRECT_URI=https://koprogo.com/auth/itsme/callback
ITSME_ISSUER=https://idp.e2e.itsme.services/v2 # Sandbox
# ITSME_ISSUER=https://idp.prd.itsme.services/v2 # Production
NISS_ENCRYPTION_KEY=<32-byte-key> # For NISS encryption
```
## itsme® Registration Process
1. Create account: https://portal.itsme.be/
2. Submit service request (KoproGo voting)
3. Provide legal entity info (company registration)
4. Sign itsme® Terms of Service
5. Integration review (sandbox testing)
6. Production approval (~2-4 weeks)
**Cost:** Free for basic usage, paid plans for high volume
## Effort Estimate
**Large** (5-7 days)
- Day 1-2: itsme® registration + sandbox setup
- Day 3-4: Backend OIDC integration + StrongAuthSession
- Day 5: Frontend integration + UX
- Day 6: Testing + audit trail
- Day 7: Security review + documentation
## Related
- **Blocks:** Issue #46 (Meeting voting system) - Strong auth required before votes
- Supports: Legal compliance (Belgian copropriété law)
- Enhances: Security posture
## Future Enhancements (Post-MVP)
- Belgian eID support (card reader)
- Qualified electronic signature (eIDAS)
- French FranceConnect integration (if expanding to France)
- Mobile biometric authentication (fallback)
## References
- itsme® Developer Portal: https://brand.belgianmobileid.be/
- itsme® Technical Documentation: https://belgianmobileid.github.io/doc/
- OpenID Connect: https://openid.net/connect/
- eIDAS Regulation: https://ec.europa.eu/digital-building-blocks/sites/display/DIGITAL/eIDAS
- Belgian eID: https://eid.belgium.be/