Compare commits

...

7 Commits

Author SHA1 Message Date
herel 1a807cfeb4 fix(security): add nonce-uniqueness test and fix caller indentation 2026-07-08 13:57:28 +02:00
herel 284e685ed3 fix(security): remove innerHTML XSS sink in showAlert 2026-07-08 13:51:45 +02:00
herel d8c947f88f fix(security): add nonce-based CSP header to frontend route 2026-07-08 13:50:23 +02:00
herel 83eb6fff89 docs(spec): add design for XSS sink remediation and CSP 2026-07-08 13:48:10 +02:00
herel ea6955f7fb build: bump to Python 3.14 and update dependencies 2026-07-08 13:42:50 +02:00
herel b7a887b68b docs(spec): add design for Python 3.14 + dependency update 2026-07-08 13:40:59 +02:00
herel baef35115a fix(oidc): Add state parameter and validate nonce against login CSRF
The OIDC implicit-flow authorize request carried no state parameter and
the nonce was generated with weak Math.random() and never validated
client- or server-side, enabling login CSRF / forced authentication /
replay (AUTHZ-VULN-05).

Generate state and nonce with the Web Crypto CSPRNG, send state in the
authorize request, and validate both state and the id_token nonce claim
in the callback before storing tokens. Forward the nonce to /validate-
token so the backend also binds the id_token to the login flow.

Fixes the forged-callback fragment attack where a planted id_token with
a mismatched nonce was accepted, stored, and used by the SPA.
2026-07-08 13:34:31 +02:00
10 changed files with 667 additions and 29 deletions
+2 -2
View File
@@ -1,5 +1,5 @@
# Multi-stage build for smaller image size
FROM python:3.9-alpine AS python-base
FROM python:3.14-alpine AS python-base
# Install runtime dependencies
RUN apk add --no-cache curl
@@ -29,7 +29,7 @@ RUN pip install --no-cache-dir -r requirements.txt
FROM python-base
# Copy installed Python packages and binaries from builder stage
COPY --from=builder /usr/local/lib/python3.9/site-packages /usr/local/lib/python3.9/site-packages
COPY --from=builder /usr/local/lib/python3.14/site-packages /usr/local/lib/python3.14/site-packages
COPY --from=builder /usr/local/bin /usr/local/bin
# Copy application files
@@ -0,0 +1,109 @@
# Python 3.14 + Dependency Update Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Update the project to run on Python 3.14 with up-to-date dependencies, removing the unused `python-jose`.
**Architecture:** Pure config/dependency bump. No application code changes. Verification is by running the existing test suite under a fresh Python 3.14 venv with the bumped dependencies, plus a Docker build.
**Tech Stack:** Python 3.14, FastAPI, uvicorn, pyjwt, requests, cryptography, pytest, Docker (alpine).
## Global Constraints
- Python target: 3.14 (local interpreter at `/opt/homebrew/bin/python3.14`, version 3.14.6)
- Version specifier style: `>=` lower-bound floors (no exact pinning)
- Drop `python-jose` entirely (unused, unmaintained)
- No changes to `main.py`, `generate_favicon.py`, templates, or the `code/`/`source/` duplicate dirs
- Keep alpine base image, multi-stage build, non-root user, healthcheck, and existing CMD in Dockerfile
---
### Task 1: Bump dependencies, fix Dockerfile, add .python-version
**Files:**
- Modify: `requirements.txt`
- Modify: `requirements-test.txt`
- Modify: `Dockerfile` (lines 2 and 32)
- Create: `.python-version`
**Interfaces:**
- Consumes: nothing (first task)
- Produces: a Python 3.14-compatible dependency set and Docker image
- [ ] **Step 1: Replace `requirements.txt` contents**
```
fastapi>=0.115.0
uvicorn>=0.30.0
pyjwt>=2.9.0
requests>=2.32.0
cryptography>=44.0.0
```
- [ ] **Step 2: Replace `requirements-test.txt` contents**
```
pytest>=8.3.0
pytest-cov>=5.0.0
coverage>=7.4.0
httpx>=0.27.0
asgi_lifespan>=2.0.0
```
- [ ] **Step 3: Edit `Dockerfile` line 2 — bump base image**
Change:
```
FROM python:3.9-alpine AS python-base
```
To:
```
FROM python:3.14-alpine AS python-base
```
- [ ] **Step 4: Edit `Dockerfile` line 32 — fix hardcoded site-packages path**
Change:
```
COPY --from=builder /usr/local/lib/python3.9/site-packages /usr/local/lib/python3.9/site-packages
```
To:
```
COPY --from=builder /usr/local/lib/python3.14/site-packages /usr/local/lib/python3.14/site-packages
```
- [ ] **Step 5: Create `.python-version`**
Contents:
```
3.14
```
- [ ] **Step 6: Recreate venv and install deps**
Run:
```bash
rm -rf venv
python3.14 -m venv venv
source venv/bin/activate
pip install --upgrade pip
pip install -r requirements.txt -r requirements-test.txt
```
Expected: exit 0, no errors. cryptography should install (wheel preferred; build deps present in Dockerfile for the container case).
- [ ] **Step 7: Run the test suite**
Run: `./run_tests.sh`
Expected: all tests pass, exit 0, coverage report generated in `htmlcov/`. No new failures vs. baseline.
- [ ] **Step 8: Verify Docker build (if Docker is available)**
Run: `docker build -t oidc-validator .`
Expected: exit 0. (If Docker is not running/installed, note this and skip — it is not a blocker for the local change.)
- [ ] **Step 9: Commit**
```bash
git add requirements.txt requirements-test.txt Dockerfile .python-version
git commit -m "build: bump to Python 3.14 and update dependencies"
```
@@ -0,0 +1,98 @@
# Python 3.14 + Dependency Update — Design
**Date:** 2026-07-08
**Status:** Approved
**Author:** brainstorming session
## Goal
Update the project to run on Python 3.14 and bring all dependencies up to recent stable versions. No functional changes to application behavior.
## Background
- The local interpreter is already Python 3.14.6 (`/opt/homebrew/bin/python3.14`).
- `Dockerfile` pins `python:3.9-alpine` and hardcodes `/usr/local/lib/python3.9/site-packages` in the final stage — a latent bug that breaks on any version bump.
- `python-jose` is declared in `requirements.txt` but never imported anywhere in the codebase (verified by grep across all `*.py`). It is effectively unmaintained (last release 3.5.0).
- `main.py` already runs fine under pydantic v2 and recent FastAPI on the local 3.14 venv; no code changes are required for the version bumps.
- The repo contains pre-existing duplicate trees (`code/`, `source/`) that are out of scope.
## Scope
**In scope:**
- `requirements.txt`
- `requirements-test.txt`
- `Dockerfile`
- New `.python-version` file
**Out of scope:**
- Any code changes to `main.py`, `generate_favicon.py`, or templates.
- Type-hint modernization (e.g. `Optional[X]``X | None`). Not caused by this change.
- Removal of unused imports in `main.py` (`HTTPException`, `Header`, `StaticFiles`). Pre-existing dead code.
- The `code/` and `source/` duplicate directories.
- Switching the Docker base image from alpine to slim. Considered and rejected — alpine is the existing convention and keeps the diff minimal.
## Decisions
### Dependency specifier strategy
Keep the existing `>=` lower-bound convention, but bump floors to recent stable majors. Rationale: matches existing convention, allows automatic patch/minor updates, avoids the maintenance burden of exact pinning.
### `python-jose` removal
Drop `python-jose` from `requirements.txt`. Rationale: unused (no imports anywhere), unmaintained, reduces install time and attack surface. All JWT operations use `pyjwt`.
### Dockerfile base image
`python:3.9-alpine``python:3.14-alpine`. Keeps the alpine + multi-stage + non-root-user convention. Only changes that are forced by the version bump will be touched (base image tag + the two hardcoded `python3.9` path references).
## Changes
### 1. `requirements.txt`
Replace contents with:
```
fastapi>=0.115.0
uvicorn>=0.30.0
pyjwt>=2.9.0
requests>=2.32.0
cryptography>=44.0.0
```
(`python-jose` line removed.)
### 2. `requirements-test.txt`
Replace contents with:
```
pytest>=8.3.0
pytest-cov>=5.0.0
coverage>=7.4.0
httpx>=0.27.0
asgi_lifespan>=2.0.0
```
### 3. `Dockerfile`
Two surgical edits:
- Line 2: `FROM python:3.9-alpine AS python-base``FROM python:3.14-alpine AS python-base`
- Line 32: `COPY --from=builder /usr/local/lib/python3.9/site-packages /usr/local/lib/python3.9/site-packages``python3.14` in both path segments.
Everything else in the Dockerfile stays unchanged: alpine packages (`curl`, `gcc musl-dev libffi-dev openssl-dev`), non-root user, port, healthcheck, CMD.
### 4. `.python-version` (new file)
Contents: `3.14`
Purpose: signals the intended interpreter to `pyenv`-aware tooling and documents the target version for local development.
## Verification
Success criteria — all must pass:
1. **Venv install:** Recreate `venv/` with `python3.14 -m venv venv`, `pip install -r requirements.txt -r requirements-test.txt` exits 0.
2. **Test suite:** `./run_tests.sh` exits 0 with the same (or better) coverage as before. No new failures.
3. **Docker build (if available):** `docker build -t oidc-validator .` exits 0.
## Risks
- **cryptography build from source on alpine:** mitigated by the existing `gcc musl-dev libffi-dev openssl-dev` build-deps line. cryptography 44+ ships musllinux wheels for 3.14, so a wheel should be available; if not, the build deps cover it.
- **New dependency majors changing behavior:** FastAPI 0.115 and pydantic v2 are already running locally without issue; no code changes observed as necessary. If tests surface breakage, it will be caught by the verification step and addressed then.
@@ -0,0 +1,168 @@
# XSS Sink Remediation + CSP
**Date:** 2026-07-08
**Status:** Approved
**Finding:** Latent XSS sink in `utils.showAlert` (`templates/index.html`) — unsanitized
value written to `innerHTML`, no CSP served. Proven exploitable to token exfiltration
via direct `localStorage` seeding, but no network-accessible write path to the seeded
value exists today, so not externally exploitable. Treat as defense-in-depth.
## Goal
Eliminate the `innerHTML` sink structurally and add a nonce-based Content-Security-Policy
so that even if a future code path writes attacker-controlled data into the alert
message, script execution and exfiltration are blocked.
## Non-goals
- Changes to the OIDC flow, token validation, or `AUTHORIZED_USERS`.
- Refactoring the single-file frontend layout.
- Adding a JS test framework.
## Approach
- **Sink:** DOM-built alerts (no `innerHTML`), chosen over escape-and-allowlist because
it removes the sink structurally rather than guarding it.
- **CSP:** Per-request nonce, chosen over hash (survives script edits) and over
`'unsafe-inline'` (which would not block the proven `onerror` exfil path).
## Design
### 1. Sink fix — `templates/index.html`
Replace `showAlert` (currently at line 162) with a version that builds the alert via
`document.createElement` and renders all dynamic content through `textContent` /
`createTextNode`. Accept either a plain string or a structured `{title, body}`:
```js
showAlert(element, message, type = 'info') {
element.replaceChildren();
const alert = document.createElement('div');
alert.className = `alert alert-${type}`;
alert.setAttribute('role', 'alert');
if (typeof message === 'string') {
alert.textContent = message;
} else if (message && typeof message === 'object') {
if (message.title) {
const strong = document.createElement('strong');
strong.textContent = message.title;
alert.appendChild(strong);
}
if (message.body) {
if (message.title) {
alert.appendChild(document.createTextNode(' '));
}
alert.appendChild(document.createTextNode(message.body));
}
}
element.appendChild(alert);
}
```
`type` is interpolated into `className` (a DOM property, not parsed as HTML), so it is
safe; it is also only ever a literal (`'success'`, `'danger'`, `'warning'`, `'info'`)
at the call sites.
#### Caller updates
Three callers use `<strong>` for bold formatting and must switch to the structured form:
| Line | Current | New |
|------|---------|-----|
| 468 | `` `<strong>Secret Phrase:</strong> ${cachedSecret}` `` | `{ title: 'Secret Phrase:', body: cachedSecret }` |
| 584 | `` `<strong>Secret Phrase:</strong> ${data.secret_phrase}` `` | `{ title: 'Secret Phrase:', body: data.secret_phrase }` |
| 621 | `` `<strong>1 Bitcoin (BTC) = CHF ${price.toLocaleString(...)}</strong>` `` | `` { title: `1 Bitcoin (BTC) = CHF ${price.toLocaleString(...)}` } `` |
The remaining 11 callers pass plain strings and require no change — the `typeof message
=== 'string'` branch renders them as `textContent`.
No caller performs HTML escaping; `textContent` makes it unnecessary. The sink is gone:
there is no `innerHTML` assignment anywhere in `showAlert`.
### 2. CSP — `main.py`
Modify `read_root()` (`main.py:326-331`) to generate a per-request nonce, inject it into
the inline `<script>` tag, and set the CSP header on the response.
Add `import secrets` to the imports at the top of `main.py`.
```python
@app.get("/", response_class=HTMLResponse)
async def read_root():
"""Serve the frontend HTML file"""
nonce = secrets.token_urlsafe(16)
with open("templates/index.html", "r") as file:
html_content = file.read()
html_content = html_content.replace(
'<script>', f'<script nonce="{nonce}">', 1
)
csp = (
"default-src 'self'; "
f"script-src 'self' 'nonce-{nonce}' https://cdn.jsdelivr.net; "
"style-src 'self' https://cdn.jsdelivr.net; "
"connect-src 'self' https://login.infomaniak.com https://api.coingecko.com; "
"img-src 'self'; "
"object-src 'none'; "
"base-uri 'self'; "
"frame-ancestors 'none'"
)
return HTMLResponse(
content=html_content,
status_code=200,
headers={"Content-Security-Policy": csp},
)
```
#### Why this is correct
- The `replace('<script>', ..., 1)` targets only the bare inline tag at `index.html:62`.
The Bootstrap tag at line 8 is `<script src=...>`, which does not match the literal
`<script>` and is left untouched. A `src`-backed script is authorized by the
`https://cdn.jsdelivr.net` entry in `script-src`, not by the nonce.
- The nonce is fresh per request (`secrets.token_urlsafe`), so an attacker who cannot
read the response body cannot guess it.
- `connect-src` allows the two `fetch` origins used by the frontend
(`api.coingecko.com` for the BTC price, `login.infomaniak.com` for any client-side
OIDC call) plus `'self'` for the app's own endpoints (`/validate-token`,
`/refresh-token`, `/config`, `/health`).
- The OIDC login redirect is a top-level navigation (`window.location`), which is not
restricted by `connect-src` or `default-src`; no `form-action` / `navigate-to`
entry is needed.
- `object-src 'none'`, `base-uri 'self'`, `frame-ancestors 'none'` are standard
hardening.
#### Scope
Only the HTML route gets the CSP header. JSON API endpoints (`/health`,
`/validate-token`, etc.) do not serve HTML and do not need a CSP header; they keep
their existing behavior. This can be widened to a middleware later if desired, but is
not required by this finding.
### 3. Verification
- **pytest (automated):** add a test asserting that `GET /` returns a
`Content-Security-Policy` header containing `nonce-` and that the response body
contains `<script nonce="`. Run the full suite (`./run_tests.sh`) and confirm it
stays green. Mind the `lru_cache` cache-clear and `CLIENT_ID` gotchas from
`AGENTS.md` when adding the test.
- **Frontend (manual):** no JS test framework in the repo. Verify by loading the app
in a browser that:
- The secret-phrase success alert still shows **Secret Phrase:** in bold followed by
the phrase.
- The BTC price alert still shows the price line in bold.
- Error alerts (e.g., trigger a token refresh failure) still render the message text.
- The browser console shows no CSP violations during a normal login flow.
## Files touched
- `templates/index.html` — `showAlert` rewrite + 3 caller updates.
- `main.py` — `import secrets`, `read_root()` nonce injection + CSP header.
- `tests/` — one new test for the CSP header + nonce injection on `GET /`.
## Risk / rollback
- Low risk. The `showAlert` signature stays backward-compatible for string callers.
- If the nonce injection breaks a downstream proxy that rewrites the HTML, rolling back
is a single-file revert of `read_root()` plus the `showAlert` block.
- If a CSP directive turns out to block a legitimate resource, the directive can be
widened without touching the sink fix.
+37 -4
View File
@@ -7,6 +7,7 @@ from typing import Optional
import jwt
import requests
import os
import secrets
from functools import lru_cache
app = FastAPI(title="OIDC Token Validator")
@@ -38,6 +39,7 @@ AUTHORIZED_USERS = {
class TokenValidationRequest(BaseModel):
id_token: str
access_token: Optional[str] = None
nonce: Optional[str] = None
class TokenRefreshRequest(BaseModel):
refresh_token: str
@@ -204,8 +206,13 @@ def refresh_token(refresh_token: str):
except Exception as e:
raise ValueError(f"Token refresh failed: {str(e)}")
def verify_token(id_token: str):
"""Verify JWT token and return payload if valid"""
def verify_token(id_token: str, expected_nonce: Optional[str] = None):
"""Verify JWT token and return payload if valid
When expected_nonce is provided, the id_token's nonce claim must match it.
This binds the token to the OIDC login flow that minted it (anti-replay /
anti-forced-authentication), complementing the client-side state/nonce checks.
"""
try:
# Validate input
if not id_token or not isinstance(id_token, str):
@@ -243,6 +250,14 @@ def verify_token(id_token: str):
issuer=ISSUER
)
# Validate nonce binding when an expected nonce is supplied
if expected_nonce is not None:
token_nonce = payload.get("nonce")
if not token_nonce:
raise ValueError("Token missing nonce claim")
if token_nonce != expected_nonce:
raise ValueError("Nonce mismatch: token is not bound to this login")
return payload
except jwt.ExpiredSignatureError:
raise ValueError("Token has expired")
@@ -261,7 +276,7 @@ async def validate_token(request: TokenValidationRequest):
try:
# Verify token
payload = verify_token(request.id_token)
payload = verify_token(request.id_token, request.nonce)
# Extract user email
user_email = payload.get("email")
@@ -312,9 +327,27 @@ async def validate_token(request: TokenValidationRequest):
@app.get("/", response_class=HTMLResponse)
async def read_root():
"""Serve the frontend HTML file"""
nonce = secrets.token_urlsafe(16)
with open("templates/index.html", "r") as file:
html_content = file.read()
return HTMLResponse(content=html_content, status_code=200)
html_content = html_content.replace(
'<script>', f'<script nonce="{nonce}">', 1
)
csp = (
"default-src 'self'; "
f"script-src 'self' 'nonce-{nonce}' https://cdn.jsdelivr.net; "
"style-src 'self' https://cdn.jsdelivr.net; "
"connect-src 'self' https://login.infomaniak.com https://api.coingecko.com; "
"img-src 'self'; "
"object-src 'none'; "
"base-uri 'self'; "
"frame-ancestors 'none'"
)
return HTMLResponse(
content=html_content,
status_code=200,
headers={"Content-Security-Policy": csp},
)
@app.get("/health")
async def health_check():
+5 -5
View File
@@ -1,5 +1,5 @@
pytest>=6.2.0
pytest-cov>=2.12.0
coverage>=5.5
httpx>=0.18.0
asgi_lifespan>=1.0.1
pytest>=8.3.0
pytest-cov>=5.0.0
coverage>=7.4.0
httpx>=0.27.0
asgi_lifespan>=2.0.0
+5 -6
View File
@@ -1,6 +1,5 @@
fastapi>=0.68.0
uvicorn>=0.15.0
pyjwt>=2.4.0
requests>=2.25.0
cryptography>=3.4.8
python-jose>=3.3.0
fastapi>=0.115.0
uvicorn>=0.30.0
pyjwt>=2.9.0
requests>=2.32.0
cryptography>=44.0.0
+82 -12
View File
@@ -131,11 +131,26 @@
},
/**
* Generate a nonce for security
* Generate a cryptographically secure nonce for OIDC anti-replay
* @returns {string} Nonce string
*/
generateNonce() {
return Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15);
return utils.generateRandomString(32);
},
/**
* Decode a JWT payload without verification (client-side nonce check only)
* @param {string} token - JWT token
* @returns {Object|null} Decoded payload or null if malformed
*/
decodeJwtPayload(token) {
try {
const part = token.split('.')[1];
const json = atob(part.replace(/-/g, '+').replace(/_/g, '/'));
return JSON.parse(json);
} catch (e) {
return null;
}
},
/**
@@ -145,7 +160,26 @@
* @param {string} type - Bootstrap alert type (success, danger, warning, info)
*/
showAlert(element, message, type = 'info') {
element.innerHTML = `<div class="alert alert-${type}" role="alert">${message}</div>`;
element.replaceChildren();
const alert = document.createElement('div');
alert.className = `alert alert-${type}`;
alert.setAttribute('role', 'alert');
if (typeof message === 'string') {
alert.textContent = message;
} else if (message && typeof message === 'object') {
if (message.title) {
const strong = document.createElement('strong');
strong.textContent = message.title;
alert.appendChild(strong);
}
if (message.body) {
if (message.title) {
alert.appendChild(document.createTextNode(' '));
}
alert.appendChild(document.createTextNode(message.body));
}
}
element.appendChild(alert);
},
/**
@@ -197,9 +231,11 @@
*/
async login() {
const nonce = utils.generateNonce();
const state = utils.generateRandomString(32);
// Store nonce for later use
// Store nonce and state for later validation (anti-replay / anti-CSRF)
localStorage.setItem('nonce', nonce);
localStorage.setItem('oidc_state', state);
// Build authorization URL for implicit flow
const authUrl = new URL(CONFIG.issuer + '/authorize');
@@ -208,6 +244,7 @@
authUrl.searchParams.append('response_type', 'id_token token');
authUrl.searchParams.append('scope', CONFIG.scopes);
authUrl.searchParams.append('nonce', nonce);
authUrl.searchParams.append('state', state);
// Redirect to authorization server
window.location.href = authUrl.toString();
@@ -224,6 +261,7 @@
const accessToken = urlParams.get('access_token');
const idToken = urlParams.get('id_token');
const refreshToken = urlParams.get('refresh_token');
const state = urlParams.get('state');
const error = urlParams.get('error');
if (error) {
@@ -232,7 +270,29 @@
return;
}
// Anti-CSRF: validate state binds this callback to a user-initiated login
const storedState = localStorage.getItem('oidc_state');
if (!state || !storedState || state !== storedState) {
alert('Security error: invalid or missing state parameter. Login aborted.');
localStorage.removeItem('oidc_state');
localStorage.removeItem('nonce');
window.history.replaceState({}, document.title, window.location.pathname);
return;
}
if (accessToken && idToken) {
// Anti-replay / anti-forced-auth: validate the id_token nonce matches the one we sent
const storedNonce = localStorage.getItem('nonce');
const payload = utils.decodeJwtPayload(idToken);
const tokenNonce = payload ? payload.nonce : null;
if (!storedNonce || !tokenNonce || tokenNonce !== storedNonce) {
alert('Security error: nonce mismatch. Login aborted to prevent session fixation.');
localStorage.removeItem('oidc_state');
localStorage.removeItem('nonce');
window.history.replaceState({}, document.title, window.location.pathname);
return;
}
// Clear URL fragment
window.history.replaceState({}, document.title, window.location.pathname);
@@ -244,8 +304,9 @@
localStorage.setItem('refresh_token', refreshToken);
}
// Clean up
localStorage.removeItem('nonce');
// Keep nonce in storage so it can be sent to /validate-token
// (cleared after validation completes in handleValidationResponse)
localStorage.removeItem('oidc_state');
// Get user info from backend after a short delay
// Only call getSecretPhrase if we don't already have a recent cached secret
@@ -351,6 +412,7 @@
localStorage.removeItem('refresh_token');
localStorage.removeItem('user_data');
localStorage.removeItem('nonce');
localStorage.removeItem('oidc_state');
localStorage.removeItem('secret_phrase');
localStorage.removeItem('secret_last_fetch');
@@ -422,7 +484,7 @@
ui.showUserInfo(user);
}
utils.showAlert(elements.secretResult, `<strong>Secret Phrase:</strong> ${cachedSecret}`, 'success');
utils.showAlert(elements.secretResult, { title: 'Secret Phrase:', body: cachedSecret }, 'success');
} else {
// Fetch fresh secret phrase and user info
setTimeout(ui.getSecretPhrase, 500);
@@ -448,6 +510,7 @@
const idToken = localStorage.getItem('id_token');
const accessToken = localStorage.getItem('access_token');
const nonce = localStorage.getItem('nonce');
if (!idToken) {
utils.showAlert(elements.secretResult, 'No ID token found. Please log in first.', 'danger');
@@ -464,7 +527,8 @@
},
body: JSON.stringify({
id_token: idToken,
access_token: accessToken
access_token: accessToken,
nonce: nonce || undefined
})
});
@@ -476,6 +540,7 @@
// Retry the request with refreshed tokens
const refreshedIdToken = localStorage.getItem('id_token');
const refreshedAccessToken = localStorage.getItem('access_token');
const refreshedNonce = localStorage.getItem('nonce');
const retryResponse = await fetch('/validate-token', {
method: 'POST',
@@ -484,7 +549,8 @@
},
body: JSON.stringify({
id_token: refreshedIdToken,
access_token: refreshedAccessToken
access_token: refreshedAccessToken,
nonce: refreshedNonce || undefined
})
});
@@ -518,6 +584,10 @@
* @param {Object} data - Response data from validation endpoint
*/
async handleValidationResponse(data) {
// Nonce is single-use for the login flow; clear it now that validation is done
localStorage.removeItem('nonce');
localStorage.removeItem('oidc_state');
if (data.valid) {
// Store user info
if (data.user) {
@@ -530,7 +600,7 @@
localStorage.setItem('secret_phrase', data.secret_phrase);
localStorage.setItem('secret_last_fetch', Date.now().toString());
utils.showAlert(elements.secretResult, `<strong>Secret Phrase:</strong> ${data.secret_phrase}`, 'success');
utils.showAlert(elements.secretResult, { title: 'Secret Phrase:', body: data.secret_phrase }, 'success');
} else {
// Clear cached secret if user is no longer authorized
localStorage.removeItem('secret_phrase');
@@ -566,8 +636,8 @@
if (data && data.bitcoin && data.bitcoin.chf) {
const price = data.bitcoin.chf;
utils.showAlert(
elements.btcPriceResult,
`<strong>1 Bitcoin (BTC) = CHF ${price.toLocaleString(undefined, {minimumFractionDigits: 2, maximumFractionDigits: 2})}</strong>`,
elements.btcPriceResult,
{ title: `1 Bitcoin (BTC) = CHF ${price.toLocaleString(undefined, {minimumFractionDigits: 2, maximumFractionDigits: 2})}` },
'success'
);
} else {
+68
View File
@@ -31,6 +31,39 @@ def test_get_client_config():
assert isinstance(data["client_id"], str)
assert len(data["client_id"]) > 0
def test_root_serves_csp_with_nonce():
"""Test that GET / returns a CSP header with a nonce and the inline script carries it."""
response = client.get("/")
assert response.status_code == 200
csp = response.headers.get("content-security-policy", "")
assert "nonce-" in csp
assert "script-src" in csp
assert "cdn.jsdelivr.net" in csp
assert "connect-src" in csp
assert "login.infomaniak.com" in csp
assert "api.coingecko.com" in csp
assert "object-src 'none'" in csp
body = response.text
assert "<script nonce=" in body
import re
match = re.search(r"nonce-([A-Za-z0-9_-]+)", csp)
assert match is not None
nonce = match.group(1)
assert f'nonce="{nonce}"' in body
def test_root_csp_nonce_is_unique_per_request():
"""Test that each GET / response gets a fresh CSP nonce (not module-level/static)."""
import re
n1 = re.search(
r"nonce-([A-Za-z0-9_-]+)",
client.get("/").headers.get("content-security-policy", ""),
).group(1)
n2 = re.search(
r"nonce-([A-Za-z0-9_-]+)",
client.get("/").headers.get("content-security-policy", ""),
).group(1)
assert n1 != n2
@patch('main.verify_token')
def test_validate_token_success(mock_verify_token):
"""Test successful token validation."""
@@ -106,6 +139,41 @@ def test_validate_token_invalid(mock_verify_token):
assert not data["valid"]
assert data["error"] == "Invalid token"
@patch('main.verify_token')
def test_validate_token_passes_nonce_to_verify(mock_verify_token):
"""The endpoint must forward the supplied nonce to verify_token (anti-replay binding)."""
mock_verify_token.return_value = {
"email": "rene.luria@infomaniak.com",
"iss": "https://login.infomaniak.com",
"aud": "test-client-id",
"nonce": "abc123",
}
response = client.post("/validate-token", json={
"id_token": "valid.token.here",
"nonce": "abc123"
})
assert response.status_code == 200
assert response.json()["valid"]
# verify_token must have been called with the nonce as the second positional arg
assert mock_verify_token.call_args.args[1] == "abc123"
@patch('main.verify_token')
def test_validate_token_rejects_nonce_mismatch(mock_verify_token):
"""A mismatched nonce (forced-auth / replay token) is rejected by the backend."""
mock_verify_token.side_effect = ValueError("Nonce mismatch: token is not bound to this login")
response = client.post("/validate-token", json={
"id_token": "forged.token.here",
"nonce": "abc123"
})
assert response.status_code == 200
data = response.json()
assert not data["valid"]
assert "Nonce mismatch" in data["error"]
def test_validate_token_empty_request():
"""Test validation with empty token."""
response = client.post("/validate-token", json={
+93
View File
@@ -58,6 +58,99 @@ def test_verify_token_no_matching_key(mock_get_header, mock_get_jwks):
with pytest.raises(ValueError, match="Unable to find matching key in JWKS"):
verify_token("some.token.string")
def _mock_jwks_and_key():
"""Helper: return (jwks, header) fixtures for reaching the jwt.decode step."""
jwks = {
"keys": [
{"kid": "test-key-id", "kty": "RSA", "n": "modulus", "e": "AQAB"}
]
}
header = {"kid": "test-key-id", "alg": "RS256"}
return jwks, header
@patch('main.jwt.decode')
@patch('main.jwt.algorithms.RSAAlgorithm.from_jwk')
@patch('main.jwt.get_unverified_header')
@patch('main.get_jwks')
def test_verify_token_nonce_match(mock_get_jwks, mock_get_header, mock_from_jwk, mock_decode):
"""verify_token returns payload when expected_nonce matches the token nonce."""
jwks, header = _mock_jwks_and_key()
mock_get_jwks.return_value = jwks
mock_get_header.return_value = header
mock_from_jwk.return_value = "mock-key"
mock_decode.return_value = {
"iss": "https://login.infomaniak.com",
"aud": "test-client-id",
"email": "rene.luria@infomaniak.com",
"nonce": "abc123",
}
payload = verify_token("some.token.string", expected_nonce="abc123")
assert payload["nonce"] == "abc123"
@patch('main.jwt.decode')
@patch('main.jwt.algorithms.RSAAlgorithm.from_jwk')
@patch('main.jwt.get_unverified_header')
@patch('main.get_jwks')
def test_verify_token_nonce_mismatch(mock_get_jwks, mock_get_header, mock_from_jwk, mock_decode):
"""verify_token rejects an id_token whose nonce does not match the expected one."""
jwks, header = _mock_jwks_and_key()
mock_get_jwks.return_value = jwks
mock_get_header.return_value = header
mock_from_jwk.return_value = "mock-key"
mock_decode.return_value = {
"iss": "https://login.infomaniak.com",
"aud": "test-client-id",
"email": "attacker@evil.com",
"nonce": "VERYWRONG_NONCE",
}
with pytest.raises(ValueError, match="Nonce mismatch"):
verify_token("some.token.string", expected_nonce="abc123")
@patch('main.jwt.decode')
@patch('main.jwt.algorithms.RSAAlgorithm.from_jwk')
@patch('main.jwt.get_unverified_header')
@patch('main.get_jwks')
def test_verify_token_nonce_missing_in_token(mock_get_jwks, mock_get_header, mock_from_jwk, mock_decode):
"""verify_token rejects a token lacking a nonce claim when an expected nonce is supplied."""
jwks, header = _mock_jwks_and_key()
mock_get_jwks.return_value = jwks
mock_get_header.return_value = header
mock_from_jwk.return_value = "mock-key"
mock_decode.return_value = {
"iss": "https://login.infomaniak.com",
"aud": "test-client-id",
"email": "rene.luria@infomaniak.com",
}
with pytest.raises(ValueError, match="Token missing nonce claim"):
verify_token("some.token.string", expected_nonce="abc123")
@patch('main.jwt.decode')
@patch('main.jwt.algorithms.RSAAlgorithm.from_jwk')
@patch('main.jwt.get_unverified_header')
@patch('main.get_jwks')
def test_verify_token_nonce_not_checked_when_none(mock_get_jwks, mock_get_header, mock_from_jwk, mock_decode):
"""verify_token skips the nonce check when no expected_nonce is provided (returning visits / refresh)."""
jwks, header = _mock_jwks_and_key()
mock_get_jwks.return_value = jwks
mock_get_header.return_value = header
mock_from_jwk.return_value = "mock-key"
mock_decode.return_value = {
"iss": "https://login.infomaniak.com",
"aud": "test-client-id",
"email": "rene.luria@infomaniak.com",
}
payload = verify_token("some.token.string")
assert payload["email"] == "rene.luria@infomaniak.com"
@patch('main.requests.get')
def test_get_well_known_config_success(mock_get):
"""Test successful retrieval of well-known configuration."""