🎯 Contexte
Pendant la césure entre mes deux semestres de Master 1 RT & Sécurité, j’ai voulu approfondir l’utilisation des APIs. En particulier, comment sécuriser correctement l’authentification quand on veut accéder aux données d’un utilisateur sur un service tiers (Spotify).
Mon cas d’usage : créer une application qui récupère les données de lecture Spotify en temps réel pour générer des visuels audiogénératif dans TouchDesigner. Pour ça, il faut que l’utilisateur m’autorise à accéder à son compte Spotify.
Cet article documente la mise en œuvre complète d’OAuth 2.1 avec PKCE (Proof Key for Code Exchange), y compris les erreurs que j’ai rencontrées.
Note : série d’articles techniques pédagogiques sur la réalisation d’un projet en cours incluant webapp et installation interactive audio-réactive. Cet article se concentre uniquement sur l’authentification OAuth. Les articles suivants couvriront l’entropie cryptographique, l’infrastructure cloud AWS, base de données Redis et SocketIO et enfin la partie visuelle audio-générative avec TouchDesigner.
Note : le site ne sera pas forcément tout le temps disponible pour l’instant.
Table des matières
- Stack du projet
- OAuth 2.1 et PKCE
- Le Flow PKCE expliqué
- Implémentation Flask : route /login
- Le challenge SHA-256 : Base64 vs Base64URL
- Implémentation Flask : route /callback
- Mon bug principal : Verifier/Challenge Mismatch
- Récupération des données Spotify
- Point clés du flow OAuth 2.1
- Prochains articles techniques sur ce projet
Stack du projet
Pour ce projet d’intégration Spotify, voici ce que j’utilise :
Backend
- Flask 3.x (Python)
- Flask-Session + CacheLib (sessions côté serveur)
- Requests (appels API Spotify)
Sécurité
- OAuth 2.1 (RFC 8252)
- PKCE (RFC 7636)
- Module
secretsPython (CSPRNG)
API Externe
- Spotify Web API
- Endpoints utilisés :
/authorize,/api/token,/v1/me/player/currently-playing
Infrastructure (sera détaillée dans l’article 3)
- Application hébergée sur AWS
- HTTPS via certificat ACM
- URL :
https://spotifytouch.jacobdufosse.dev
OAuth 2.1 et PKCE
Pour le projet, question sécurité, j’ai choisi de stocker le secret (le verifier - nous le verrons après) côté serveur et non côté client.
Donc si le serveur est correctement protégé, je réduis les risques de sécurité, j’aurai pu dans ce cas utiliser le flow 2.0 mais comme il sera bientôt déprécié même dans ce cas côté serveur, j’ai décidé de l’utiliser dès maintenant.
Le Flow OAuth 2.0 (Authorization Code Flow)
The authorization code flow with PKCE is the recommended authorization flow if you’re implementing authorization in a mobile app, single page web apps, or any other type of application where the client secret can’t be safely stored. https://developer.spotify.com/documentation/web-api/tutorials/code-pkce-flow
Il faut comprendre le flow 2.0 pour intégrer correctement le flow 2.1, voici une description.
Étapes OAuth 2.0 :
Redirection vers Spotify :
https://accounts.spotify.com/authorize ?response_type=code &client_id=MON_CLIENT_ID &redirect_uri=https://mon-app.com/callback &scope=user-read-currently-playingL’utilisateur s’authentifie et accepte
Spotify renvoie un code :
https://mon-app.com/callback?code=ABC123XYZÉchange du code contre un token :
POST https://accounts.spotify.com/api/token Content-Type: application/x-www-form-urlencoded grant_type=authorization_code &code=ABC123XYZ &redirect_uri=https://mon-app.com/callback &client_id=MON_CLIENT_ID &client_secret=MON_SECRET # ⚠️ Le problème est iciSpotify renvoie l’access_token
Le Problème de sécurité
Vulnérabilité principale : le client_secret doit rester secret, mais dans une application web publique, il finit souvent :
- hardcodé dans le code JavaScript (visible dans le navigateur)
- présent dans les logs serveur
- exposé si le code source fuite
Scénario d’attaque :
Si un attaquant intercepte le code d’autorisation (via logs proxy, historique navigateur, ou man-in-the-middle), et qu’il connaît le client_secret, il peut :
- prendre ce code
- l’échanger lui-même contre un
access_token - accéder au compte Spotify de la victime
La solution : OAuth 2.1 + PKCE
PKCE (Proof Key for Code Exchange) a été créé initialement pour les applications mobiles, mais OAuth 2.1 le rend obligatoire pour tous.
Le principe : au lieu de compter uniquement sur le client_secret (qui peut fuiter), on génère un secret temporaire pour chaque tentative de connexion.
Avantages :
- même si le
codeest intercepté, l’attaquant ne peut rien en faire sans le secret temporaire - le secret temporaire ne transite jamais sur le réseau pendant la phase publique
- fonctionne même si le
client_secretest connu
Le Flow PKCE expliqué
Les trois acteurs
Avant de plonger dans le flow, clarifions les rôles :
- End User : l’utilisateur final qui possède un compte Spotify et veut autoriser mon application
- My App : mon application Flask qui demande l’accès aux données (client OAuth)
- Spotify : le serveur d’autorisation qui protège les ressources de l’utilisateur
Vue d’ensemble du flow PKCE
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ End User │ │ Mon App │ │ Spotify │
│ (Navigateur) │ │ (Flask) │ │ API │
└──────┬───────┘ └──────┬───────┘ └──────┬───────┘
│ │ │
│ 1. Click "Connect Spotify" │ │
├────────────────────────────────>│ │
│ │ │
│ │ 2. Génère code_verifier (random)│
│ │ Calcule code_challenge │
│ │ (SHA256 du verifier) │
│ │ │
│ │ 3. GET /authorize │
│ │ ?code_challenge=... │
│ │ &code_challenge_method=S256 │
│ ├────────────────────────────────>│
│ │ │
│ 4. Redirect vers Spotify │ │
│<────────────────────────────────┤ │
│ │ │
│ 5. Affiche page de login │ │
│<────────────────────────────────┼─────────────────────────────────┤
│ │ │
│ 6. User entre identifiants │ │
│ et accepte permissions │ │
├─────────────────────────────────┼────────────────────────────────>│
│ │ │
│ │ │
│ 7. Redirect /callback?code=ABC │ │
│<────────────────────────────────┼─────────────────────────────────┤
├────────────────────────────────>│ │
│ │ │
│ │ 8. POST /token │
│ │ code=ABC │
│ │ code_verifier=... (en clair) │
│ ├────────────────────────────────>│
│ │ │
│ │ Spotify vérifie : │
│ │ SHA256(verifier)==challenge? │
│ │ │
│ │ 9. access_token + refresh_token │
│ │<────────────────────────────────┤
│ │ │
│ 10. Affiche interface │ │
│ (avec données utilisateur) │ │
│<────────────────────────────────┤ │
│ │ │
1. Mon App génère deux valeurs :
code_verifier: secret aléatoire stocké côté serveurcode_challenge: hash SHA-256 du verifier
2. Redirection vers Spotify :
- GET
/authorize?code_challenge=... - l’utilisateur est envoyé sur la page de connexion Spotify
3. Authentification de l’utilisateur :
- l’utilisateur entre ses identifiants Spotify
- il accepte les permissions demandées par mon app
4. Retour vers mon app :
- Redirect
/callback?code=ABC123 - Spotify renvoie un code d’autorisation temporaire
5. Échange du code contre un token :
- POST
/tokenaveccode=ABC123+code_verifier=...(en clair) - Spotify vérifie :
SHA256(code_verifier) == code_challenge?
6. Réception de l’access_token :
- si la vérification réussit, Spotify renvoie le token
- mon app peut maintenant appeler l’API au nom de l’utilisateur
Les deux valeurs clés
Code verifier
- chaîne aléatoire de 43-128 caractères
- générée par le client (mon application)
- Reste secrète côté serveur pendant toute l’étape publique
- envoyée seulement à la toute fin, lors de l’échange de token via requête HTTPS
Code challenge
- Hash SHA-256 du Code Verifier
- encodé en base64url
- envoyé publiquement à Spotify dès le début via URL
- Spotify le stocke et l’utilisera pour vérifier le verifier plus tard
Concept du hashage et du PKCE : Spotify peut vérifier que l’on possède le verifier (en le hachant et comparant au challenge), mais personne ne peut retrouver le verifier à partir du challenge (SHA-256 est irréversible).
Implémentation des routes Flask : /login
Étape 1 : génération du Code Verifier
code_verifier = high-entropy cryptographic random STRING
using unreserved characters [A-Z] / [a-z] / [0-9] / "-" / "." / "_" / "~"
Minimum length: 43 characters
Maximum length: 128 characters
Implémentation python :
import secrets
import base64
@app.route('/login')
def login():
# Génération de 64 octets aléatoires (512 bits d'entropie)
verifier_bytes = secrets.token_bytes(64)
# Encodage en base64url
code_verifier = base64.urlsafe_b64encode(verifier_bytes).rstrip(b'=').decode()
# Stockage dans la session avec flask_session et cachelib (côté serveur)
session['verifier'] = code_verifier
Pourquoi secrets.token_bytes() ?
Le module secrets utilise le CSPRNG (Cryptographically Secure Pseudo-Random Number Generator) du système. Sur Linux, il lit /dev/urandom, qui est alimenté par des sources d’entropie matérielles.
Alternatives que je n’ai PAS utilisées :
random.random()→ PRNG simple, prévisible, inacceptable en cryptographieos.urandom()→ Correct, maissecretsest l’interface recommandée Python 3.6+
Résultat typique :
# Exemple de code_verifier généré
'6LHrL9vN2FxWM3_Tk8pQ4uZn1Dj7RsY0EaXcwVbgIfH5oh2GmJKl'
# (86 caractères, alphabet base64url sans '=')
Étape 2 : création du Code Challenge
Processus :
- Prendre le
code_verifier(la string stockée) - Le hasher avec SHA-256
- Encoder le résultat en base64url
Implémentation python :
import hashlib
# ... suite de la fonction login()
# Création du hash SHA-256
challenge = hashlib.sha256(session['verifier'].encode('utf-8'))
hashed_challenge = challenge.digest()
# Encodage base64url (sans padding '=')
code_challenge = base64.urlsafe_b64encode(hashed_challenge).rstrip(b'=').decode()
Point important : je hashe session['verifier'].encode('utf-8') — c’est-à-dire la string que j’ai stockée, pas les bytes originaux. C’est ce qui m’a causé mon plus gros bug (voir section debugging).
Étape 3 : construction de l’URL d’autorisation
import urllib.parse
# ... suite de la fonction login()
url = "https://accounts.spotify.com/authorize"
params = {
'response_type': 'code',
'client_id': clientId, # Variable globale avec mon ID Spotify
'scope': 'user-read-currently-playing',
'code_challenge_method': 'S256', # SHA-256
'code_challenge': code_challenge,
'redirect_uri': redirectUri # https://spotifytouch.jacobdufosse.dev/callback
}
auth_url = f'{url}?{urllib.parse.urlencode(params)}'
return redirect(auth_url)
Paramètres importants :
code_challenge_method:S256(SHA-256) recommandécode_challenge: le hash, PAS le verifierredirect_uri: doit matcher exactement celui déclaré dans le dashboard Spotify
À ce stade : le verifier est stocké dans session['verifier'] côté serveur. L’utilisateur est redirigé vers Spotify avec seulement le challenge visible dans l’URL.
Le challenge SHA-256 : Base64 vs Base64URL
Le piège du Base64 standard
Quand j’ai commencé, j’ai utilisé base64.b64encode() classique :
# ❌ Version avec base64 standard
code_challenge = base64.b64encode(hashed_challenge).decode()
print(code_challenge)
# Résultat : "SGVsbG8gV29ybGQh+T9/a2E="
# ↑ ↑ ↑
Problème dans une URL :
https://accounts.spotify.com/authorize
?code_challenge=SGVsbG8gV29ybGQh+T9/a2E=
↑ ↑ ↑
- Le
+est interprété comme un espace - Le
/est un séparateur de chemin - Le
=est un séparateur de paramètre
Résultat : Spotify reçoit une chaîne corrompue → erreur 400 Bad Request.
La solution : utiliser Base64URL
Différence d’utilisation entre base64 standard et base64url :
Différences :
| Position | Base64 Standard | Base64URL | Raison |
|---|---|---|---|
| Caractère 62 | + | - | + = espace dans les URLs |
| Caractère 63 | / | _ | / = séparateur de chemin |
| Padding | = | Supprimé | = = séparateur de paramètres |
Code correct :
# ✅ Version avec base64url
code_challenge = base64.urlsafe_b64encode(hashed_challenge).rstrip(b'=').decode()
print(code_challenge)
# Résultat : "SGVsbG8gV29ybGQh-T9_a2E"
# ↑ ↑
# - _ (safe dans les URLs)
Pourquoi supprimer le padding = ?
Le padding n’est pas strictement nécessaire pour décoder, et sa suppression :
- Réduit la taille de l’URL
- Évite toute ambiguïté dans les parsers de query strings
- Est explicitement recommandé par le RFC 7636
Implémentation route Flask : /callback
Réception du code d’autorisation
A ce stade spotify renvoie à mon serveur un code d’autorisation à placer dans ma prochaine requête pour obtenir un token.
Et donc, après que l’utilisateur a accepté sur Spotify (voir précédemment), il est redirigé vers une URL composé de mon adresse de redirection définie dans mon dashboard app spotify + le code d’autorisation :
https://spotifytouch.jacobdufosse.dev/callback?code=AQBx7y...
Implémentation Flask Python :
@app.route('/callback')
def callback():
# Récupération du code d'autorisation
code = request.args.get('code')
if not code:
# L'utilisateur a refusé ou erreur
return redirect('/')
Échange du code contre un access token
Requête POST vers Spotify :
# Construction de la requête
headers = {
'Content-Type': 'application/x-www-form-urlencoded'
}
data = {
'client_id': clientId,
'grant_type': 'authorization_code',
'code': code,
'redirect_uri': redirectUri,
'code_verifier': session.get('verifier') # ⚠️ En clair !
}
# Appel API
r = requests.post(
"https://accounts.spotify.com/api/token",
headers=headers,
data=data
)
token_data = r.json()
Point important : on envoie le code_verifier en clair à Spotify. C’est sécurisé car :
- La requête est en HTTPS (TLS 1.3)
- Elle va directement de mon serveur à Spotify (pas via le navigateur)
- Spotify va vérifier que
SHA256(code_verifier) == code_challenge
Gestion du token
if 'access_token' in token_data:
# Stockage dans la session (côté serveur)
session['access_token'] = token_data['access_token']
session['refresh_token'] = token_data.get('refresh_token')
# Redirection vers l'interface
return redirect('/dashboard')
else:
# Erreur d'authentification
print(f"Erreur Spotify : {token_data}", flush=True)
return render_template("callback.html", response=token_data)
Réponse typique de Spotify (succès) :
{
"access_token": "BQD8x...",
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "AQC9y...",
"scope": "user-read-currently-playing"
}
Session storage vs cookies :
Je stocke les tokens dans
session[], qui utilise la librairie CacheLib : https://cachelib.readthedocs.io/en/stable/ et flask_session côté serveur : https://flask-session.readthedocs.io/en/latest/usage.html#using-cachelib-as-a-session-backend . Le cookie envoyé au client ne contient qu’unsession_id, pas les tokens eux-mêmes. Je vais bientôt implémenter Redis à la place en raison de sa simplicitié et efficacité à gérer les sessions et les listes d’attente dont je vais avoir besoin pour gérer l’ordre de passage des utilisateurs.
Avantage sécurité :
- Même si le cookie est intercepté, l’attaquant n’a qu’un ID sans valeur
- Les tokens sensibles ne transitent jamais vers le navigateur
- Invalidation facile (supprimer le fichier session côté serveur)
Mon bug principal : Verifier/Challenge Mismatch
L’erreur “code_verifier was incorrect”
Après avoir implémenté tout le flow, j’ai eu cette erreur systématique lors du callback :
{
"error": "invalid_grant",
"error_description": "code_verifier was incorrect"
}
Ma première implémentation (buguée)
@app.route('/login')
def login():
# Génération du verifier
verifier_bytes = secrets.token_bytes(64)
# ❌ Erreur : je stockais la version base64url
session['verifier'] = base64.urlsafe_b64encode(verifier_bytes).rstrip(b'=').decode()
# ❌ Mais je hachais les bytes originaux !
challenge = hashlib.sha256(verifier_bytes) # Les bytes, pas la string
code_challenge = base64.urlsafe_b64encode(challenge.digest()).rstrip(b'=').decode()
# ... redirection vers Spotify
Ce qui se passait :
Étape login :
- Je générais
verifier_bytes(ex:b'\x4f\x2a...') - Je les encodais en base64url →
session['verifier'] = "Tyqx..." - Mais je hachais les bytes bruts :
SHA256(b'\x4f\x2a...')
- Je générais
Étape callback :
- Spotify recevait
code_verifier = "Tyqx..."(la string) - Il la hachait :
SHA256("Tyqx...") - Résultat : hash différent de celui que j’avais envoyé !
- Spotify recevait
Schéma du problème :
Moi (login) :
bytes bruts → SHA256 → challenge_1
Spotify (callback) :
string base64 → SHA256 → challenge_2
challenge_1 ≠ challenge_2 → ERREUR
Résolution
⚠️ Attention : S’assurer de hasher exactement la même valeur que celle qui est envoyée au callback. C’est-à-dire que si je stocke une string base64url dans
session['verifier'], alors je dois hasher cette string base64url — et pas les bytes originaux générés parsecrets.token_bytes(). Le hash SHA-256 ne peut se faire qu’avec des bytes, donc il faut bien penser à encoder la string :.encode('utf-8'). Attention aux types !
Implémentation corrigée :*
@app.route('/login')
def login():
# Génération
verifier_bytes = secrets.token_bytes(64)
# ✅ Encodage et stockage
code_verifier = base64.urlsafe_b64encode(verifier_bytes).rstrip(b'=').decode()
session['verifier'] = code_verifier
# ✅ Hash de la STRING stockée
challenge = hashlib.sha256(code_verifier.encode('utf-8'))
code_challenge = base64.urlsafe_b64encode(challenge.digest()).rstrip(b'=').decode()
# ... redirection
Maintenant :
Moi (login) :
bytes → base64url → string → SHA256 → challenge
Spotify (callback) :
reçoit string → SHA256 → challenge
Les deux SHA256 portent sur la MÊME string → ✅ Match
Debug avec Docker Logs
Pour identifier le problème, j’ai ajouté des logs :
@app.route('/callback')
def callback():
# ...
print(f"DEBUG - Verifier envoyé : {session.get('verifier')}", flush=True)
r = requests.post(...)
print(f"DEBUG - Réponse Spotify : {r.json()}", flush=True)
Visualisation en temps réel :
docker logs -f app
Ce que je voyais :
DEBUG - Verifier envoyé : Tyqx7LnM...
DEBUG - Réponse Spotify : {'error': 'invalid_grant', 'error_description': 'code_verifier was incorrect'}
Le flush=True est crucial : sans lui, les logs sont bufferisés et n’apparaissent qu’à la fin du processus.
Récupération des données Spotify
Appel API : Currently Playing
Une fois authentifié, je peux interroger l’API Spotify :
def afficher_musique(token):
headers = {"Authorization": f"Bearer {token}"}
r = requests.get(
"https://api.spotify.com/v1/me/player/currently-playing",
headers=headers
)
if r.status_code == 200:
current_track = r.json()
if current_track.get("is_playing"):
# Extraction des métadonnées
track_name = current_track['item']['name']
artist_name = current_track['item']['artists'][0]['name']
album_art_url = current_track['item']['album']['images'][0]['url']
progress_ms = current_track['progress_ms']
print(f"En cours : {track_name} - {artist_name}")
return current_track
elif r.status_code == 204:
# Aucune musique en cours
print("Player en pause")
return None
Réponse JSON Typique
{
"item": {
"name": "Some Day I Will",
"id": "4etHuFADJ2zYI1t40Eiru",
"artists": [
{
"name": "Barry Can't Swim"
}
],
"album": {
"name": "On & On",
"images": [
{
"url": "https://i.scdn.co/image/ab67616d0000b273..."
}
]
},
"duration_ms": 176939
},
"progress_ms": 16141,
"is_playing": true
}
Affichage dans l’interface
@app.route('/callback')
def callback():
# ... authentification
if 'access_token' in session:
current_track = afficher_musique(session['access_token'])
return render_template("callback.html", current_track=current_track)
Template Jinja2 (callback.html) :
<body>
<div class="header">
<span class="queue-badge">File d'attente : #{{position}}</span>
<h1>Juke-V-Box</h1>
</div>
<div class="card">
{% if not current_track or current_track.is_playing == false %}
<div class="loader"></div>
<h3>Waiting for signal...</h3>
<p class="status-msg">Lancez une musique sur votre application Spotify pour synchroniser l'installation.</p>
{% else %}
<img src="{{ current_track.item.album.images[0].url }}" class="album-art" alt="Album Art">
<div class="track-name">{{current_track.item.name}}</div>
<div class="artist-name">{{current_track.item.artists[0].name}}</div>
<p class="status-msg">Synchronisation active avec le Master Player</p>
<div class="debug-info">
<div><span>ID:</span> {{current_track.item.id}}</div>
<div><span>TS:</span> {{current_track.timestamp}}</div>
<div><span>POS:</span> {{current_track.progress_ms}} ms</div>
</div>
{% endif %}
</div>
</body>
Points clés du flow OAuth 2.1
OAuth 2.1 est désormais obligatoire
C’est une refonte de la sécurité. PKCE est désormais obligatoire, même pour les applications web serveur. Les anciennes implémentations avec client_secret exposé côté client ne sont plus acceptables.
La documentation officielle doit être lue patiemment et entièrement (si possible)
Je suis trop pressé et passe souvent à côté des explications en sautant une ligne et une autre dans la documentation. En croyant gagner du temps, je perds du temps. Il faut lire les documentation avec patience. Tout est dedans.
Génère le verifier avec secrets.token_bytes() pas random ou uuid
Hashe la valeur que tu stockes si tu stockes du base64url, hashe du base64url
Utilise base64url, pas base64 standard supprime le padding = avec .rstrip("=")
Stocke les tokens côté serveur
Le debug Docker est impératif
Pas OAuth mais important :
_Les logs Docker en temps réel (docker logs -f) sont essentiels._
_Log tout pendant le développement avec flush=True pour Docker ex: print(f"DEBUG - Réponse Spotify : {r.json()}", flush=True)_
Prochains articles techniques sur ce projet
Cet article couvre uniquement l’authentification OAuth 2.1 + PKCE. Les prochains articles de cette série :
** Article 2 : entropie & cryptographie **
- PRNG vs CSPRNG
- /dev/urandom, rdrand, rngd
- Pourquoi secrets.token_bytes() est crucial
** Article 3 : infrastructure AWS sécurisée **
- EC2 + Docker
- Application Load Balancer
- Certificate Manager (HTTPS/TLS)
- Security Groups (pare-feu cloud)
- DNS & CNAME
- Health checks
** Article 4 : architecture temps réel avec Redis **
- Migration sessions : CacheLib → Redis
- File d’attente multi-utilisateurs (Redis queues)
- WebSocket avec Flask-SocketIO
- Synchronisation état entre users
- Gestion des tokens (refresh automatique)
** Article 5 : pipeline visuel génératif **
- Intégration TouchDesigner
- Parsing JSON → visuels
- Analyse audio
- Multi-écrans
Références
- RFC 7636 - Proof Key for Code Exchange (PKCE)
- RFC 8252 - OAuth 2.0 for Native Apps
- Spotify Web API - Authorization Guide
- Python secrets Module Documentation
Projet en développement (l’app est pour l’instant disponible et parfois non, car j’éteinds mes instances EC2 AWS pour diminuer les couts… étudiant oblige) : spotifytouch.jacobdufosse.dev
Code source : Disponible sur https://gitlab.com/Jacob_974/spotify_for_touchdesigner
Contact : LinkedIn






