mirror of
https://github.com/suitenumerique/people
synced 2026-04-25 17:15:13 +02:00
🧑💻(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:
68
src/backend/demo/forms.py
Normal file
68
src/backend/demo/forms.py
Normal 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",
|
||||
)
|
||||
175
src/backend/demo/templates/demo/token_exchange_form.html
Normal file
175
src/backend/demo/templates/demo/token_exchange_form.html
Normal 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
15
src/backend/demo/urls.py
Normal 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
30
src/backend/demo/views.py
Normal 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
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user