feat: implement OpenID Connect authentication with Infomaniak

This commit is contained in:
2025-08-19 11:16:07 +02:00
parent 5957868e0f
commit 5b1d741a16
3 changed files with 255 additions and 33 deletions

View File

@@ -76,15 +76,34 @@ Then open your browser at `http://localhost:8000`. The web interface allows you
- View upcoming games and practices - View upcoming games and practices
- See detailed information about events including player rosters - See detailed information about events including player rosters
### Authentication
The web interface supports two authentication methods:
1. **Infomaniak OpenID Connect (Recommended)**: Click the "Se connecter avec Infomaniak" button to authenticate using Infomaniak's OIDC provider. Only users in the allowed list will be granted access.
2. **Static API Key**: For development purposes, you can still use `abc` as the token.
### Environment Variables
To configure OIDC authentication, set the following environment variables:
- `CLIENT_ID`: Your OIDC client ID (default: 8ea04fbb-4237-4b1d-a895-0b3575a3af3f)
- `CLIENT_SECRET`: Your OIDC client secret
- `REDIRECT_URI`: The redirect URI (default: http://localhost:8000/callback)
- `ALLOWED_USERS`: Comma-separated list of allowed email addresses (e.g., "user1@example.com,user2@example.com")
The web API provides the following endpoints: The web API provides the following endpoints:
- `/schedule` - Get the schedule for a specific account - `/schedule` - Get the schedule for a specific account
- `/game/{game_id}` - Get details for a specific game - `/game/{game_id}` - Get details for a specific game
- `/accounts` - Get a list of available accounts - `/accounts` - Get a list of available accounts
- `/health` - Health check endpoint - `/health` - Health check endpoint
- `/login` - Initiate OIDC login flow
- `/callback` - Handle OIDC callback
- `/userinfo` - Get user information
All endpoints (except `/health`) require an Authorization header with a Bearer token. All endpoints (except `/health`, `/login`, and `/callback`) require an Authorization header with a Bearer token.
For development purposes, you can use `abc` as the token.
## mobile functions ## mobile functions

View File

@@ -13,7 +13,20 @@
<div class="container mt-4"> <div class="container mt-4">
<h1 class="mb-4 text-center">MyIce - Games</h1> <h1 class="mb-4 text-center">MyIce - Games</h1>
<div id="apikeyContainer" class="mb-3"></div> <div id="loginContainer" class="mb-3">
<div id="oidcLoginSection">
<button id="oidcLoginBtn" class="btn btn-primary">Se connecter avec Infomaniak</button>
</div>
<div id="apikeySection" style="display: none;">
<label for="apikey" class="form-label">API Key</label>
<input type="text" id="apikey" class="form-control" placeholder="Entrez votre API Key">
<button id="validateApiKey" class="btn btn-success mt-2">Valider</button>
</div>
<div id="connectedUser" style="display: none;">
<p>Connecté en tant que: <span id="userName"></span></p>
<button id="disconnect" class="btn btn-danger">Déconnecter</button>
</div>
</div>
<div id="eventFilters" style="display: none;"> <div id="eventFilters" style="display: none;">
<div class="mb-3"> <div class="mb-3">
@@ -50,7 +63,10 @@
<script> <script>
document.addEventListener("DOMContentLoaded", function () { document.addEventListener("DOMContentLoaded", function () {
const apikeyContainer = document.getElementById("apikeyContainer"); const loginContainer = document.getElementById("loginContainer");
const oidcLoginSection = document.getElementById("oidcLoginSection");
const apikeySection = document.getElementById("apikeySection");
const connectedUser = document.getElementById("connectedUser");
const eventFilters = document.getElementById("eventFilters"); const eventFilters = document.getElementById("eventFilters");
const accountSelect = document.getElementById("account"); const accountSelect = document.getElementById("account");
const agegroupSelect = document.getElementById("agegroup"); const agegroupSelect = document.getElementById("agegroup");
@@ -62,33 +78,86 @@
let storedApiKey = localStorage.getItem("apikey"); let storedApiKey = localStorage.getItem("apikey");
let lastFetchedEvents = []; let lastFetchedEvents = [];
let storedAccount = localStorage.getItem("account") || "default"; let storedAccount = localStorage.getItem("account") || "default";
let userInfo = JSON.parse(localStorage.getItem("userInfo") || "null");
// Handle OIDC callback
const urlParams = new URLSearchParams(window.location.search);
const code = urlParams.get('code');
if (code) {
// We're coming back from OIDC login
exchangeCodeForToken(code);
// Remove code from URL
window.history.replaceState({}, document.title, "/");
}
function renderLoginSection() {
if (storedApiKey || userInfo) {
// User is logged in
connectedUser.style.display = "block";
oidcLoginSection.style.display = "none";
apikeySection.style.display = "none";
const userNameElement = document.getElementById("userName");
if (userInfo && userInfo.email) {
userNameElement.textContent = userInfo.email;
} else {
userNameElement.textContent = "Utilisateur";
}
function renderApiKeyInput() {
if (storedApiKey) {
apikeyContainer.innerHTML = `<button id="disconnect" class="btn btn-danger">Déconnecter</button>`;
document.getElementById("disconnect").addEventListener("click", () => {
localStorage.removeItem("apikey");
localStorage.removeItem("account");
location.reload();
});
eventFilters.style.display = "block"; eventFilters.style.display = "block";
// Load available accounts from server or use predefined ones
updateAccountOptions(); updateAccountOptions();
fetchEvents(storedApiKey, storedAccount); if (storedApiKey) {
fetchEvents(storedApiKey, storedAccount);
}
} else { } else {
apikeyContainer.innerHTML = ` // User is not logged in
<label for="apikey" class="form-label">API Key</label> connectedUser.style.display = "none";
<input type="text" id="apikey" class="form-control" placeholder="Entrez votre API Key"> oidcLoginSection.style.display = "block";
<button id="validateApiKey" class="btn btn-success mt-2">Valider</button> apikeySection.style.display = "none";
`;
eventFilters.style.display = "none"; eventFilters.style.display = "none";
document.getElementById("validateApiKey").addEventListener("click", saveApiKey);
document.getElementById("apikey").addEventListener("keypress", function (event) { document.getElementById("oidcLoginBtn").addEventListener("click", initiateOIDCLogin);
if (event.key === "Enter") {
saveApiKey();
}
});
} }
// Add disconnect handler
const disconnectBtn = document.getElementById("disconnect");
if (disconnectBtn) {
disconnectBtn.addEventListener("click", logout);
}
}
function initiateOIDCLogin() {
// Redirect to backend login endpoint
window.location.href = `${apiBaseUrl}/login`;
}
function exchangeCodeForToken(code) {
fetch(`${apiBaseUrl}/callback?code=${code}&state=ignored`)
.then(response => response.json())
.then(data => {
if (data.access_token) {
localStorage.setItem("apikey", data.access_token);
localStorage.setItem("userInfo", JSON.stringify(data.user));
storedApiKey = data.access_token;
userInfo = data.user;
renderLoginSection();
} else {
alert("Échec de la connexion OIDC");
}
})
.catch(error => {
console.error("Erreur lors de l'échange du code:", error);
alert("Échec de la connexion OIDC");
});
}
function logout() {
localStorage.removeItem("apikey");
localStorage.removeItem("account");
localStorage.removeItem("userInfo");
storedApiKey = null;
userInfo = null;
location.reload();
} }
function saveApiKey() { function saveApiKey() {
@@ -128,7 +197,7 @@
}); });
} }
renderApiKeyInput(); renderLoginSection();
accountSelect.addEventListener("change", () => { accountSelect.addEventListener("change", () => {
const selectedAccount = accountSelect.value; const selectedAccount = accountSelect.value;
@@ -140,7 +209,7 @@
fetchButton.addEventListener("click", () => { fetchButton.addEventListener("click", () => {
if (!storedApiKey) { if (!storedApiKey) {
alert("Veuillez entrer une clé API"); alert("Veuillez vous connecter");
return; return;
} }
const selectedAccount = accountSelect.value; const selectedAccount = accountSelect.value;
@@ -155,11 +224,20 @@
fetch(`${apiBaseUrl}/schedule?account=${account}`, { fetch(`${apiBaseUrl}/schedule?account=${account}`, {
headers: { "Authorization": `Bearer ${apiKey}` } headers: { "Authorization": `Bearer ${apiKey}` }
}) })
.then(response => response.json()) .then(response => {
if (response.status === 401) {
// Token expired or invalid
logout();
return;
}
return response.json();
})
.then(data => { .then(data => {
lastFetchedEvents = data.filter(event => event.event === "Jeu"); if (data) {
updateAgeGroupOptions(lastFetchedEvents); lastFetchedEvents = data.filter(event => event.event === "Jeu");
displayEvents(lastFetchedEvents); updateAgeGroupOptions(lastFetchedEvents);
displayEvents(lastFetchedEvents);
}
}) })
.catch(error => console.error("Erreur lors du chargement des événements:", error)); .catch(error => console.error("Erreur lors du chargement des événements:", error));
} }

View File

@@ -1,12 +1,31 @@
import logging import logging
import requests import requests
import os
from typing import Annotated from typing import Annotated
from fastapi import FastAPI, Header, HTTPException, status from fastapi import FastAPI, Header, HTTPException, status, Request
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse from fastapi.responses import FileResponse, RedirectResponse
from pydantic import BaseModel from pydantic import BaseModel
from . import myice from . import myice
# OIDC Configuration
CLIENT_ID = os.environ.get("CLIENT_ID", "8ea04fbb-4237-4b1d-a895-0b3575a3af3f")
CLIENT_SECRET = os.environ.get(
"CLIENT_SECRET", "5iXycu7aU6o9e17NNTOUeetQkRkBqQlomoD2hTyLGLTAcwj0dwkkLH3mz1IrZSmJ"
)
REDIRECT_URI = os.environ.get("REDIRECT_URI", "http://localhost:8000/callback")
AUTHORIZATION_ENDPOINT = "https://login.infomaniak.com/authorize"
TOKEN_ENDPOINT = "https://login.infomaniak.com/token"
USERINFO_ENDPOINT = "https://login.infomaniak.com/oauth2/userinfo"
JWKS_URI = "https://login.infomaniak.com/oauth2/jwks"
# Allowed users (based on email claim)
ALLOWED_USERS = (
os.environ.get("ALLOWED_USERS", "").split(",")
if os.environ.get("ALLOWED_USERS")
else []
)
origins = ["*"] origins = ["*"]
app = FastAPI() app = FastAPI()
@@ -41,10 +60,43 @@ class AuthHeaders(BaseModel):
return self.Authorization.split(" ")[1] return self.Authorization.split(" ")[1]
def authorized(self) -> bool: def authorized(self) -> bool:
# First check if it's a valid OIDC token
if self.validate_oidc_token():
return True
# Fallback to the old static key for compatibility
if self.token() == "abc": if self.token() == "abc":
return True return True
return False return False
def validate_oidc_token(self) -> bool:
try:
token = self.token()
# Basic validation - in production, you would validate the JWT signature
# and check issuer, audience, expiration, etc.
import base64
import json
# Decode JWT header and payload (without verification for simplicity in this example)
parts = token.split(".")
if len(parts) != 3:
return False
# Decode payload (part 1)
payload = parts[1]
# Add padding if needed
payload += "=" * (4 - len(payload) % 4)
decoded_payload = base64.urlsafe_b64decode(payload)
payload_data = json.loads(decoded_payload)
# Check if user is in allowed list
user_email = payload_data.get("email")
if ALLOWED_USERS and user_email not in ALLOWED_USERS:
return False
return True
except Exception:
return False
class HealthCheck(BaseModel): class HealthCheck(BaseModel):
"""Response model to validate and return when performing a health check.""" """Response model to validate and return when performing a health check."""
@@ -71,6 +123,79 @@ async def favico():
return FileResponse("favicon.ico") return FileResponse("favicon.ico")
@app.get("/login")
async def login(request: Request):
"""Redirect to Infomaniak OIDC login"""
import urllib.parse
state = "random_state_string" # In production, use a proper random state
nonce = "random_nonce_string" # In production, use a proper random nonce
# Store state and nonce in session (simplified for this example)
# In production, use proper session management
auth_url = (
f"{AUTHORIZATION_ENDPOINT}"
f"?client_id={CLIENT_ID}"
f"&redirect_uri={urllib.parse.quote(REDIRECT_URI)}"
f"&response_type=code"
f"&scope=openid email profile"
f"&state={state}"
f"&nonce={nonce}"
)
return RedirectResponse(auth_url)
@app.get("/callback")
async def callback(code: str, state: str):
"""Handle OIDC callback and exchange code for token"""
# Exchange authorization code for access token
token_data = {
"grant_type": "authorization_code",
"client_id": CLIENT_ID,
"client_secret": CLIENT_SECRET,
"code": code,
"redirect_uri": REDIRECT_URI,
}
response = requests.post(TOKEN_ENDPOINT, data=token_data)
token_response = response.json()
if "access_token" not in token_response:
raise HTTPException(status_code=400, detail="Failed to obtain access token")
access_token = token_response["access_token"]
# Get user info
userinfo_response = requests.get(
USERINFO_ENDPOINT, headers={"Authorization": f"Bearer {access_token}"}
)
userinfo = userinfo_response.json()
# Check if user is in allowed list
user_email = userinfo.get("email")
if ALLOWED_USERS and user_email not in ALLOWED_USERS:
raise HTTPException(status_code=403, detail="User not authorized")
# Return token to frontend
return {"access_token": access_token, "user": userinfo}
@app.get("/userinfo")
async def userinfo(headers: Annotated[AuthHeaders, Header()]):
"""Get user info from access token"""
access_token = headers.token()
userinfo_response = requests.get(
USERINFO_ENDPOINT, headers={"Authorization": f"Bearer {access_token}"}
)
return userinfo_response.json()
@app.get("/schedule") @app.get("/schedule")
async def schedule( async def schedule(
headers: Annotated[AuthHeaders, Header()], headers: Annotated[AuthHeaders, Header()],