🧑‍💻(demo) add a token exchange test page

This must only be used in development mode to test
proper behavior of the token exchange API.
This commit is contained in:
Quentin BEY
2026-02-04 15:04:10 +01:00
parent 466e93d329
commit e5c86d29c2
5 changed files with 294 additions and 0 deletions

68
src/backend/demo/forms.py Normal file
View File

@@ -0,0 +1,68 @@
"""Forms for the demo helpers."""
from django import forms
class TokenExchangeDemoForm(forms.Form):
"""Simple form to craft a token exchange request payload."""
client_id = forms.CharField(
required=False,
label="client_id (Basic Auth)",
widget=forms.TextInput(attrs={"autocomplete": "off"}),
help_text="Used only to build the Authorization header",
)
client_secret = forms.CharField(
required=False,
label="client_secret (Basic Auth)",
widget=forms.PasswordInput(render_value=True, attrs={"autocomplete": "off"}),
help_text="Used only to build the Authorization header",
)
grant_type = forms.CharField(
required=True,
initial="urn:ietf:params:oauth:grant-type:token-exchange",
widget=forms.TextInput(attrs={"size": 60}),
)
subject_token = forms.CharField(
required=True,
widget=forms.Textarea(
attrs={"rows": 3, "placeholder": "Access token to exchange"}
),
)
subject_token_type = forms.CharField(
required=True,
initial="urn:ietf:params:oauth:token-type:access_token",
widget=forms.TextInput(attrs={"size": 60}),
)
requested_token_type = forms.CharField(
required=False,
widget=forms.TextInput(attrs={"size": 60}),
help_text="Optional: e.g. urn:ietf:params:oauth:token-type:jwt",
)
audience = forms.CharField(
required=False,
widget=forms.TextInput(attrs={"size": 60}),
help_text="Optional: space separated audience identifiers",
)
scope = forms.CharField(
required=False,
widget=forms.TextInput(attrs={"size": 60}),
help_text="Optional: space separated scopes",
)
actor_token = forms.CharField(
required=False,
widget=forms.Textarea(attrs={"rows": 2}),
)
actor_token_type = forms.CharField(
required=False,
widget=forms.TextInput(attrs={"size": 60}),
)
resource = forms.CharField(
required=False,
widget=forms.TextInput(attrs={"size": 60}),
)
expires_in = forms.IntegerField(
required=False,
min_value=1,
help_text="Optional: lifetime in seconds",
)

View File

@@ -0,0 +1,175 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Token exchange demo</title>
<style>
body { font-family: sans-serif; margin: 2rem; max-width: 960px; }
form { display: grid; grid-template-columns: 1fr; gap: 0.5rem; }
.field { display: flex; flex-direction: column; gap: 0.15rem; }
label { font-weight: 600; }
input[type="text"], input[type="password"], textarea { padding: 0.35rem; font-size: 0.95rem; }
small { color: #555; }
button { margin-top: 0.5rem; padding: 0.6rem 1.2rem; font-size: 1rem; }
.response { margin-top: 1.5rem; }
pre { background: #f4f4f4; padding: 1rem; overflow: auto; }
.jwt { margin-top: 1rem; }
.jwt h2 { margin: 0 0 0.35rem; font-size: 1rem; }
.jwt .row { display: grid; grid-template-columns: 1fr 1fr; gap: 0.75rem; }
.hidden { display: none; }
</style>
</head>
<body>
<h1>Token exchange demo</h1>
<p>Use this form to POST to <code>/auth/token/exchange/</code>. Basic Auth is built from the client_id and client_secret fields.</p>
<form id="token-exchange-form" data-target="{{ token_exchange_url }}">
{% csrf_token %}
<div class="field">{{ form.client_id.label_tag }}{{ form.client_id }}<small>{{ form.client_id.help_text }}</small></div>
<div class="field">{{ form.client_secret.label_tag }}{{ form.client_secret }}<small>{{ form.client_secret.help_text }}</small></div>
<div class="field">{{ form.grant_type.label_tag }}{{ form.grant_type }}</div>
<div class="field">{{ form.subject_token.label_tag }}{{ form.subject_token }}</div>
<div class="field">{{ form.subject_token_type.label_tag }}{{ form.subject_token_type }}</div>
<div class="field">{{ form.requested_token_type.label_tag }}{{ form.requested_token_type }}<small>{{ form.requested_token_type.help_text }}</small></div>
<div class="field">{{ form.audience.label_tag }}{{ form.audience }}<small>{{ form.audience.help_text }}</small></div>
<div class="field">{{ form.scope.label_tag }}{{ form.scope }}<small>{{ form.scope.help_text }}</small></div>
<div class="field">{{ form.actor_token.label_tag }}{{ form.actor_token }}</div>
<div class="field">{{ form.actor_token_type.label_tag }}{{ form.actor_token_type }}</div>
<div class="field">{{ form.resource.label_tag }}{{ form.resource }}</div>
<div class="field">{{ form.expires_in.label_tag }}{{ form.expires_in }}<small>{{ form.expires_in.help_text }}</small></div>
<button type="submit">Send exchange request</button>
</form>
<div class="response">
<div id="response-status"></div>
<pre id="exchange-response"></pre>
</div>
<div class="response jwt hidden" id="jwt-section">
<h2>Decoded JWT</h2>
<div id="jwt-error"></div>
<div class="row">
<div>
<strong>Header</strong>
<pre id="jwt-header"></pre>
</div>
<div>
<strong>Payload</strong>
<pre id="jwt-payload"></pre>
</div>
</div>
</div>
<script>
(() => {
const form = document.getElementById("token-exchange-form");
const statusEl = document.getElementById("response-status");
const outputEl = document.getElementById("exchange-response");
const jwtSection = document.getElementById("jwt-section");
const jwtHeaderEl = document.getElementById("jwt-header");
const jwtPayloadEl = document.getElementById("jwt-payload");
const jwtErrorEl = document.getElementById("jwt-error");
const resetJwt = () => {
if (!jwtSection) return;
jwtSection.classList.add("hidden");
jwtHeaderEl.textContent = "";
jwtPayloadEl.textContent = "";
jwtErrorEl.textContent = "";
};
const base64UrlDecode = (segment) => {
const normalized = segment.replace(/-/g, "+").replace(/_/g, "/");
const padded = normalized + "=".repeat((4 - (normalized.length % 4)) % 4);
return atob(padded);
};
const decodeJwt = (token) => {
const parts = token.split(".");
if (parts.length < 2) {
return { error: "Token does not look like a JWT (missing segments)." };
}
try {
return {
header: JSON.parse(base64UrlDecode(parts[0])),
payload: JSON.parse(base64UrlDecode(parts[1])),
};
} catch (err) {
return { error: `Unable to decode JWT: ${err}` };
}
};
const renderJwtIfAny = (responseData, requestedType) => {
resetJwt();
if (!jwtSection || !responseData || typeof responseData !== "object") return;
const issuedType = (responseData.issued_token_type || "").toString().toLowerCase();
const requested = (requestedType || "").toString().toLowerCase();
const wantsJwt = issuedType === "urn:ietf:params:oauth:token-type:jwt" || requested === "urn:ietf:params:oauth:token-type:jwt";
const token = responseData.access_token || responseData.id_token || responseData.token;
if (!wantsJwt || !token) return;
const decoded = decodeJwt(token);
jwtSection.classList.remove("hidden");
if (decoded.error) {
jwtErrorEl.textContent = decoded.error;
return;
}
jwtHeaderEl.textContent = JSON.stringify(decoded.header, null, 2);
jwtPayloadEl.textContent = JSON.stringify(decoded.payload, null, 2);
};
const buildPayload = (formData) => {
const body = new URLSearchParams();
for (const [key, value] of formData.entries()) {
if (!value) continue;
body.append(key, value);
}
return body;
};
form.addEventListener("submit", async (event) => {
event.preventDefault();
statusEl.textContent = "Sending...";
outputEl.textContent = "";
resetJwt();
const formData = new FormData(form);
const clientId = formData.get("client_id") || "";
const clientSecret = formData.get("client_secret") || "";
formData.delete("client_id");
formData.delete("client_secret");
const headers = { "Content-Type": "application/x-www-form-urlencoded" };
if (clientId || clientSecret) {
headers.Authorization = `Basic ${btoa(`${clientId}:${clientSecret}`)}`;
}
const response = await fetch(form.dataset.target, {
method: "POST",
headers,
body: buildPayload(formData),
credentials: "include",
}).catch((error) => {
statusEl.textContent = "Network error";
outputEl.textContent = error;
throw error;
});
const text = await response.text();
statusEl.textContent = `Status: ${response.status} ${response.statusText}`;
try {
const parsed = JSON.parse(text);
outputEl.textContent = JSON.stringify(parsed, null, 2);
renderJwtIfAny(parsed, formData.get("requested_token_type"));
} catch (err) {
outputEl.textContent = text;
}
});
})();
</script>
</body>
</html>

15
src/backend/demo/urls.py Normal file
View File

@@ -0,0 +1,15 @@
"""URL configuration for the demo helpers."""
from django.urls import path
from .views import TokenExchangeDemoView
app_name = "demo"
urlpatterns = [
path(
"token-exchange/",
TokenExchangeDemoView.as_view(),
name="token-exchange-demo",
),
]

30
src/backend/demo/views.py Normal file
View File

@@ -0,0 +1,30 @@
"""Views for demo application."""
from django.urls import reverse_lazy
from django.views.generic import TemplateView
from .forms import TokenExchangeDemoForm
class TokenExchangeDemoView(TemplateView):
"""Demo view to help test token exchange flows."""
template_name = "demo/token_exchange_form.html"
form_class = TokenExchangeDemoForm
http_method_names = ["get"]
def get_context_data(self, **kwargs):
"""Add form and token exchange URL to the context."""
context = super().get_context_data(**kwargs)
context["form"] = self.form_class(
initial={
"client_id": "client_id",
"client_secret": "client_secret",
"grant_type": "urn:ietf:params:oauth:grant-type:token-exchange",
"subject_token_type": "urn:ietf:params:oauth:token-type:access_token",
"subject_token": self.request.session.get("oidc_access_token"),
"requested_token_type": "urn:ietf:params:oauth:token-type:jwt",
}
)
context["token_exchange_url"] = reverse_lazy("token-exchange")
return context

View File

@@ -41,6 +41,12 @@ if settings.DEBUG:
+ debug_urls.urlpatterns
)
# We double-check here just in case
if settings.ENVIRONMENT != "production":
urlpatterns += [
path("demo/", include("demo.urls"))
]
if settings.USE_SWAGGER or settings.DEBUG:
urlpatterns += [
path(