Browse Source

feat: refactor example-app with a new config (#4569)

This is a preparation for KubeCon 2026:
1. Restyle the app
2. Refactor advanced configuration options
3. Move embedded templates and css to separate files

Signed-off-by: maksim.nabokikh <max.nabokih@gmail.com>
pull/4570/head
Maksim Nabokikh 3 weeks ago committed by GitHub
parent
commit
5d27abc117
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 84
      examples/example-app/main.go
  2. 106
      examples/example-app/static/app.js
  3. 20
      examples/example-app/static/dex-glyph-color.svg
  4. 357
      examples/example-app/static/style.css
  5. 82
      examples/example-app/static/token.js
  6. 438
      examples/example-app/templates.go
  7. 62
      examples/example-app/templates/index.html
  8. 64
      examples/example-app/templates/token.html

84
examples/example-app/main.go

@ -3,11 +3,8 @@ package main
import (
"bytes"
"context"
"crypto/rand"
"crypto/sha256"
"crypto/tls"
"crypto/x509"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
@ -17,7 +14,7 @@ import (
"net/http/httputil"
"net/url"
"os"
"strings"
"slices"
"time"
"github.com/coreos/go-oidc/v3/oidc"
@ -33,8 +30,8 @@ var (
)
func init() {
codeVerifier = generateCodeVerifier()
codeChallenge = generateCodeChallenge(codeVerifier)
codeVerifier = oauth2.GenerateVerifier()
codeChallenge = oauth2.S256ChallengeFromVerifier(codeVerifier)
}
type app struct {
@ -43,8 +40,9 @@ type app struct {
pkce bool
redirectURI string
verifier *oidc.IDTokenVerifier
provider *oidc.Provider
verifier *oidc.IDTokenVerifier
provider *oidc.Provider
scopesSupported []string
// Does the provider use "offline_access" scope to request a refresh token
// or does it use "access_type=offline" (e.g. Google)?
@ -188,7 +186,9 @@ func cmd() *cobra.Command {
a.provider = provider
a.verifier = provider.Verifier(&oidc.Config{ClientID: a.clientID})
a.scopesSupported = s.ScopesSupported
http.Handle("/static/", http.StripPrefix("/static/", staticHandler))
http.HandleFunc("/", a.handleIndex)
http.HandleFunc("/login", a.handleLogin)
http.HandleFunc(u.Path, a.handleCallback)
@ -226,7 +226,10 @@ func main() {
}
func (a *app) handleIndex(w http.ResponseWriter, r *http.Request) {
renderIndex(w)
renderIndex(w, indexPageData{
ScopesSupported: a.scopesSupported,
LogoURI: dexLogoDataURI,
})
}
func (a *app) oauth2Config(scopes []string) *oauth2.Config {
@ -240,15 +243,19 @@ func (a *app) oauth2Config(scopes []string) *oauth2.Config {
}
func (a *app) handleLogin(w http.ResponseWriter, r *http.Request) {
var scopes []string
if extraScopes := r.FormValue("extra_scopes"); extraScopes != "" {
scopes = strings.Split(extraScopes, " ")
}
var clients []string
if crossClients := r.FormValue("cross_client"); crossClients != "" {
clients = strings.Split(crossClients, " ")
if err := r.ParseForm(); err != nil {
http.Error(w, fmt.Sprintf("failed to parse form: %v", err), http.StatusBadRequest)
return
}
// Only use scopes that are checked in the form
scopes := r.Form["extra_scopes"]
clients := r.Form["cross_client"]
for _, client := range clients {
if client == "" {
continue
}
scopes = append(scopes, "audience:server:client_id:"+client)
}
connectorID := ""
@ -257,7 +264,7 @@ func (a *app) handleLogin(w http.ResponseWriter, r *http.Request) {
}
authCodeURL := ""
scopes = append(scopes, "openid", "profile", "email")
scopes = uniqueStrings(scopes)
var authCodeOptions []oauth2.AuthCodeOption
@ -266,13 +273,28 @@ func (a *app) handleLogin(w http.ResponseWriter, r *http.Request) {
authCodeOptions = append(authCodeOptions, oauth2.SetAuthURLParam("code_challenge_method", "S256"))
}
a.oauth2Config(scopes)
if r.FormValue("offline_access") == "yes" {
authCodeOptions = append(authCodeOptions, oauth2.AccessTypeOffline)
// Check if offline_access scope is present to determine offline access mode
hasOfflineAccess := false
for _, scope := range scopes {
if scope == "offline_access" {
hasOfflineAccess = true
break
}
}
if a.offlineAsScope {
scopes = append(scopes, "offline_access")
if hasOfflineAccess && !a.offlineAsScope {
// Provider uses access_type=offline instead of offline_access scope
authCodeOptions = append(authCodeOptions, oauth2.AccessTypeOffline)
// Remove offline_access from scopes as it's not supported
filteredScopes := make([]string, 0, len(scopes))
for _, scope := range scopes {
if scope != "offline_access" {
filteredScopes = append(filteredScopes, scope)
}
}
scopes = filteredScopes
}
authCodeURL = a.oauth2Config(scopes).AuthCodeURL(exampleAppState, authCodeOptions...)
// Parse the auth code URL and safely add connector_id parameter if provided
@ -369,23 +391,17 @@ func (a *app) handleCallback(w http.ResponseWriter, r *http.Request) {
}
buff := new(bytes.Buffer)
if err := json.Indent(buff, []byte(claims), "", " "); err != nil {
if err := json.Indent(buff, claims, "", " "); err != nil {
http.Error(w, fmt.Sprintf("error indenting ID token claims: %v", err), http.StatusInternalServerError)
return
}
renderToken(w, a.redirectURI, rawIDToken, accessToken, token.RefreshToken, buff.String())
renderToken(w, r.Context(), a.provider, a.redirectURI, rawIDToken, accessToken, token.RefreshToken, buff.String())
}
func generateCodeVerifier() string {
bytes := make([]byte, 64) // 86 symbols Base64URL
if _, err := rand.Read(bytes); err != nil {
log.Fatalf("rand.Read error: %v", err)
}
return base64.RawURLEncoding.EncodeToString(bytes)
}
func uniqueStrings(values []string) []string {
slices.Sort(values)
values = slices.Compact(values)
func generateCodeChallenge(verifier string) string {
hash := sha256.Sum256([]byte(verifier))
return base64.RawURLEncoding.EncodeToString(hash[:])
return values
}

106
examples/example-app/static/app.js

@ -0,0 +1,106 @@
(function() {
const crossClientInput = document.getElementById("cross_client_input");
const crossClientList = document.getElementById("cross-client-list");
const addClientBtn = document.getElementById("add-cross-client");
const scopesList = document.getElementById("scopes-list");
const customScopeInput = document.getElementById("custom_scope_input");
const addCustomScopeBtn = document.getElementById("add-custom-scope");
// Default scopes that should be checked by default
const defaultScopes = ["openid", "profile", "email", "offline_access"];
// Check default scopes on page load
document.addEventListener("DOMContentLoaded", function() {
const checkboxes = scopesList.querySelectorAll('input[type="checkbox"]');
checkboxes.forEach(cb => {
if (defaultScopes.includes(cb.value)) {
cb.checked = true;
}
});
});
function addCrossClient(value) {
const trimmed = value.trim();
if (!trimmed) return;
const chip = document.createElement("div");
chip.className = "chip";
const text = document.createElement("span");
text.textContent = trimmed;
const hidden = document.createElement("input");
hidden.type = "hidden";
hidden.name = "cross_client";
hidden.value = trimmed;
const remove = document.createElement("button");
remove.type = "button";
remove.textContent = "×";
remove.onclick = () => crossClientList.removeChild(chip);
chip.append(text, hidden, remove);
crossClientList.appendChild(chip);
}
function addCustomScope(scope) {
const trimmed = scope.trim();
if (!trimmed || !scopesList) return;
// Check if scope already exists
const existingCheckboxes = scopesList.querySelectorAll('input[type="checkbox"]');
for (const cb of existingCheckboxes) {
if (cb.value === trimmed) {
cb.checked = true;
return;
}
}
// Add new scope checkbox
const scopeItem = document.createElement("div");
scopeItem.className = "scope-item";
const checkbox = document.createElement("input");
checkbox.type = "checkbox";
checkbox.name = "extra_scopes";
checkbox.value = trimmed;
checkbox.id = "scope_custom_" + trimmed;
checkbox.checked = true;
const label = document.createElement("label");
label.htmlFor = checkbox.id;
label.textContent = trimmed;
scopeItem.append(checkbox, label);
scopesList.appendChild(scopeItem);
}
addClientBtn?.addEventListener("click", () => {
addCrossClient(crossClientInput.value);
crossClientInput.value = "";
crossClientInput.focus();
});
crossClientInput?.addEventListener("keydown", (e) => {
if (e.key === "Enter") {
e.preventDefault();
addCrossClient(crossClientInput.value);
crossClientInput.value = "";
}
});
addCustomScopeBtn?.addEventListener("click", () => {
addCustomScope(customScopeInput.value);
customScopeInput.value = "";
customScopeInput.focus();
});
customScopeInput?.addEventListener("keydown", (e) => {
if (e.key === "Enter") {
e.preventDefault();
addCustomScope(customScopeInput.value);
customScopeInput.value = "";
}
});
})();

20
examples/example-app/static/dex-glyph-color.svg

@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 17.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
width="112px" height="109px" viewBox="0 0 112 109" enable-background="new 0 0 112 109" xml:space="preserve">
<g>
<path fill="#449FD8" d="M88.345,51.574c7.588-3.55,12.764-10.49,14.175-18.53C96.396,19.395,84.663,9.054,70.094,4.851
c4.923,7.133,7.272,15.583,6.771,24.17C83.311,34.466,87.716,42.55,88.345,51.574z M27.27,38.542
c-8.207-1.045-16.333,1.973-21.858,8.054C3.23,61.683,7.869,76.84,18.099,88.158c-0.527-8.64,1.856-17.306,6.831-24.483
C22.19,55.048,23.32,45.944,27.27,38.542z M33.01,76.928c-2.997,8.079-1.755,17.193,3.642,24.215
c12.155,4.943,26.051,5.146,38.643-0.035c-7.818-2.516-14.886-7.518-19.887-14.731C47.233,86.23,39.124,83.032,33.01,76.928z
M63.122,22.202C61.615,14.044,56.069,6.819,47.892,3.47C33.778,5.711,20.745,13.966,12.76,26.631
c8.115-2.487,16.74-2.178,24.529,0.639C44.816,22.008,54.043,20.144,63.122,22.202z M85.891,66.457
c-3.086,7.399-8.722,13.188-15.678,16.61c6.194,5.604,14.805,7.758,22.852,5.834c9.054-9.587,13.884-22.198,13.9-35.009
C101.549,60.198,94.131,64.67,85.891,66.457z"/>
<g>
<circle fill="#F04D5C" cx="56.035" cy="53.892" r="15.972"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

357
examples/example-app/static/style.css

@ -0,0 +1,357 @@
body {
font-family: Arial, sans-serif;
background-color: #f2f2f2;
margin: 0;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
flex-direction: column;
padding: 20px;
}
/* Token page layout - no centering */
body.token-page {
display: block;
align-items: flex-start;
justify-content: flex-start;
padding: 20px;
}
.container {
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
max-width: 600px;
}
.logo {
max-width: 100%;
width: 200px;
height: auto;
margin-bottom: 30px;
display: block;
}
form {
background-color: #fff;
padding: 30px;
border-radius: 8px;
width: 100%;
}
/* Shadow only for main login form */
.container form {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.primary-action {
margin-bottom: 20px;
}
.advanced {
margin-top: 20px;
background: #f9fbfd;
border: 1px solid #dbe7f3;
border-radius: 6px;
padding: 15px;
}
.advanced summary {
cursor: pointer;
font-weight: bold;
color: #3F9FD8;
user-select: none;
text-align: center;
}
.advanced summary:hover {
color: #357FAA;
}
.app-description {
text-align: center;
color: #666;
font-size: 14px;
margin-bottom: 25px;
line-height: 1.5;
}
.app-description a {
color: #3F9FD8;
text-decoration: none;
}
.app-description a:hover {
text-decoration: underline;
}
.field {
margin-top: 15px;
display: flex;
flex-direction: column;
gap: 8px;
}
.field label {
font-weight: 600;
color: #333;
font-size: 14px;
}
.inline-input {
display: flex;
gap: 8px;
}
.inline-input input[type="text"] {
flex: 1;
}
input[type="text"] {
padding: 10px 12px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
outline: none;
transition: border-color 0.2s;
}
input[type="text"]:focus {
border-color: #3F9FD8;
}
.checkbox-field {
flex-direction: row;
align-items: center;
gap: 8px;
margin-top: 10px;
}
.checkbox-field label {
margin: 0;
font-weight: 500;
}
input[type="checkbox"] {
width: 18px;
height: 18px;
cursor: pointer;
}
.scopes-list {
display: flex;
flex-direction: column;
gap: 8px;
max-height: 200px;
overflow-y: auto;
padding: 10px;
background: white;
border: 1px solid #ddd;
border-radius: 4px;
}
.scope-item {
display: flex;
align-items: center;
gap: 8px;
}
.scope-item input[type="checkbox"] {
margin: 0;
}
.scope-item label {
margin: 0;
font-weight: 400;
font-size: 13px;
cursor: pointer;
}
input[type="submit"], button {
padding: 12px 20px;
background-color: #3F9FD8;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
font-weight: 500;
transition: background-color 0.2s;
}
/* Full width submit button only on main login form */
form input[type="submit"] {
width: 100%;
font-size: 16px;
}
/* Token page forms should have auto-width buttons */
body.token-page input[type="submit"] {
width: auto;
font-size: 14px;
}
input[type="submit"]:hover, button:hover {
background-color: #357FAA;
}
button {
white-space: nowrap;
}
.chip-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
min-height: 32px;
margin-top: 8px;
}
.chip {
display: inline-flex;
align-items: center;
gap: 6px;
background: #e9f4fb;
border: 1px solid #cde5f5;
border-radius: 16px;
padding: 6px 12px;
font-size: 13px;
}
.chip button {
border: none;
background: transparent;
cursor: pointer;
font-weight: bold;
color: #3F9FD8;
padding: 0;
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
}
.chip button:hover {
background: #d0e8f5;
}
.hint {
font-size: 12px;
color: #666;
margin-top: 4px;
}
.copy-btn {
padding: 4px 10px;
background-color: #3F9FD8;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 11px;
transition: background-color 0.2s;
margin-left: 8px;
white-space: nowrap;
}
.copy-btn:hover {
background-color: #357FAA;
}
/* Token page styles */
.back-button {
display: inline-block;
padding: 8px 16px;
background-color: #EF4B5C;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 12px;
text-decoration: none;
transition: background-color 0.3s ease, transform 0.2s ease;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
position: fixed;
right: 20px;
bottom: 20px;
}
.back-button:hover {
background-color: #C43B4B;
}
.token-block {
background-color: #fff;
padding: 10px 15px;
border-radius: 8px;
margin-bottom: 15px;
word-wrap: break-word;
display: flex;
flex-direction: column;
gap: 5px;
position: relative;
overflow: hidden;
border: 1px solid #e0e0e0;
}
.token-block form {
margin: 10px 0 0 0;
padding: 0;
background-color: transparent;
box-shadow: none;
border-radius: 0;
}
.token-title {
font-weight: bold;
display: flex;
justify-content: space-between;
align-items: center;
}
.token-title a {
font-size: 0.9em;
text-decoration: none;
color: #3F9FD8;
}
.token-title a:hover {
text-decoration: underline;
}
.token-code {
overflow-wrap: break-word;
word-break: break-all;
white-space: normal;
}
pre {
white-space: pre-wrap;
background-color: #f9f9f9;
padding: 8px;
border-radius: 4px;
border: 1px solid #ddd;
margin: 0;
font-family: 'Courier New', Courier, monospace;
overflow-x: auto;
font-size: 0.9em;
position: relative;
margin-top: 5px;
}
pre .key {
color: #c00;
}
pre .string {
color: #080;
}
pre .number {
color: #00f;
}

82
examples/example-app/static/token.js

@ -0,0 +1,82 @@
// Simple JSON syntax highlighter
document.addEventListener("DOMContentLoaded", function() {
const claimsElement = document.getElementById("claims");
if (claimsElement) {
try {
const json = JSON.parse(claimsElement.textContent);
claimsElement.innerHTML = syntaxHighlight(json);
} catch (e) {
console.error("Invalid JSON in claims:", e);
}
}
});
function syntaxHighlight(json) {
if (typeof json != 'string') {
json = JSON.stringify(json, undefined, 2);
}
json = json.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
return json.replace(/("(\\u[\da-fA-F]{4}|\\[^u]|[^\\"])*"(\s*:)?|\b(true|false|null)\b|\b\d+\b)/g, function (match) {
let cls = 'number';
if (/^"/.test(match)) {
if (/:$/.test(match)) {
cls = 'key';
} else {
cls = 'string';
}
} else if (/true|false/.test(match)) {
cls = 'boolean';
} else if (/null/.test(match)) {
cls = 'null';
}
return '<span class="' + cls + '">' + match + '</span>';
});
}
function copyPublicKey() {
const publicKeyElement = document.getElementById("public-key");
if (!publicKeyElement) return;
const text = publicKeyElement.textContent;
// Use modern clipboard API if available
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).then(() => {
showCopyFeedback("Copied!");
}).catch(err => {
console.error("Failed to copy:", err);
fallbackCopy(text);
});
} else {
fallbackCopy(text);
}
}
function fallbackCopy(text) {
const textarea = document.createElement("textarea");
textarea.value = text;
textarea.style.position = "fixed";
textarea.style.opacity = "0";
document.body.appendChild(textarea);
textarea.select();
try {
document.execCommand("copy");
showCopyFeedback("Copied!");
} catch (err) {
console.error("Fallback copy failed:", err);
showCopyFeedback("Failed to copy");
}
document.body.removeChild(textarea);
}
function showCopyFeedback(message) {
const btn = event.target;
const originalText = btn.textContent;
btn.textContent = message;
btn.style.backgroundColor = "#28a745";
setTimeout(() => {
btn.textContent = originalText;
btn.style.backgroundColor = "";
}, 2000);
}

438
examples/example-app/templates.go

@ -1,366 +1,158 @@
package main
import (
"context"
"crypto/rsa"
"crypto/x509"
"embed"
"encoding/base64"
"encoding/json"
"encoding/pem"
"html/template"
"io/fs"
"log"
"math/big"
"net/http"
"net/url"
"github.com/coreos/go-oidc/v3/oidc"
)
const css = `
body {
font-family: Arial, sans-serif;
background-color: #f2f2f2;
margin: 0;
}
//go:embed templates/*.html
var templatesFS embed.FS
.header {
text-align: center;
margin-bottom: 20px;
}
//go:embed static/*
var staticFS embed.FS
.dex {
font-size: 2em;
font-weight: bold;
color: #3F9FD8; /* Main color */
}
const dexLogoDataURI = "/static/dex-glyph-color.svg"
.example-app {
font-size: 1em;
color: #EF4B5C; /* Secondary color */
}
var (
indexTmpl *template.Template
tokenTmpl *template.Template
staticHandler http.Handler
)
.form-instructions {
text-align: center;
margin-bottom: 15px;
font-size: 1em;
color: #555;
func init() {
var err error
indexTmpl, err = template.ParseFS(templatesFS, "templates/index.html")
if err != nil {
log.Fatalf("failed to parse index template: %v", err)
}
hr {
border: none;
border-top: 1px solid #ccc;
margin-top: 10px;
margin-bottom: 20px;
tokenTmpl, err = template.ParseFS(templatesFS, "templates/token.html")
if err != nil {
log.Fatalf("failed to parse token template: %v", err)
}
label {
flex: 1;
font-weight: bold;
color: #333;
// Create handler for static files
staticSubFS, err := fs.Sub(staticFS, "static")
if err != nil {
log.Fatalf("failed to create static sub filesystem: %v", err)
}
staticHandler = http.FileServer(http.FS(staticSubFS))
}
p {
margin-bottom: 15px;
display: flex;
align-items: center;
}
func renderIndex(w http.ResponseWriter, data indexPageData) {
renderTemplate(w, indexTmpl, data)
}
input[type="text"] {
flex: 2;
padding: 8px;
border: 1px solid #ccc;
border-radius: 4px;
outline: none;
}
type indexPageData struct {
ScopesSupported []string
LogoURI string
}
input[type="checkbox"] {
margin-left: 10px;
transform: scale(1.2);
}
type tokenTmplData struct {
IDToken string
IDTokenJWTLink string
AccessToken string
AccessTokenJWTLink string
RefreshToken string
RedirectURL string
Claims string
PublicKeyPEM string
}
.back-button {
display: inline-block;
padding: 8px 16px;
background-color: #EF4B5C; /* Secondary color */
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 12px;
text-decoration: none;
transition: background-color 0.3s ease, transform 0.2s ease;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
position: fixed;
right: 20px;
bottom: 20px;
}
func generateJWTIOLink(token string, provider *oidc.Provider, ctx context.Context) string {
// JWT.io doesn't support automatic public key via URL parameter
// The public key is displayed separately on the page for manual copy-paste
return "https://jwt.io/#debugger-io?token=" + url.QueryEscape(token)
}
.back-button:hover {
background-color: #C43B4B; /* Darker shade of secondary color */
func getPublicKeyPEM(provider *oidc.Provider) string {
if provider == nil {
return ""
}
.token-block {
background-color: #fff;
padding: 10px 15px;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
margin-bottom: 15px;
word-wrap: break-word;
display: flex;
flex-direction: column;
gap: 5px;
position: relative;
jwksURL := provider.Endpoint().AuthURL
if len(jwksURL) > 5 {
jwksURL = jwksURL[:len(jwksURL)-5] + "/keys"
} else {
return ""
}
.token-title {
font-weight: bold;
display: flex;
justify-content: space-between;
align-items: center;
resp, err := http.Get(jwksURL)
if err != nil {
return ""
}
defer resp.Body.Close()
.token-title a {
font-size: 0.9em;
text-decoration: none;
color: #3F9FD8; /* Main color */
var jwks struct {
Keys []json.RawMessage `json:"keys"`
}
.token-title a:hover {
text-decoration: underline;
if err := json.NewDecoder(resp.Body).Decode(&jwks); err != nil || len(jwks.Keys) == 0 {
return ""
}
.token-code {
overflow-wrap: break-word;
word-break: break-all;
white-space: normal;
}
pre {
white-space: pre-wrap;
background-color: #f9f9f9;
padding: 8px;
border-radius: 4px;
border: 1px solid #ddd;
margin: 0;
font-family: 'Courier New', Courier, monospace;
overflow-x: auto;
font-size: 0.9em;
position: relative;
margin-top: 5px;
var key struct {
N string `json:"n"`
E string `json:"e"`
Kty string `json:"kty"`
}
pre .key {
color: #c00;
if err := json.Unmarshal(jwks.Keys[0], &key); err != nil || key.Kty != "RSA" {
return ""
}
pre .string {
color: #080;
nBytes, err1 := base64.RawURLEncoding.DecodeString(key.N)
eBytes, err2 := base64.RawURLEncoding.DecodeString(key.E)
if err1 != nil || err2 != nil {
return ""
}
pre .number {
color: #00f;
var eInt int
for _, b := range eBytes {
eInt = eInt<<8 | int(b)
}
`
var indexTmpl = template.Must(template.New("index.html").Parse(`<html>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Example App - Login</title>
<style>
` + css + `
body {
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
flex-direction: column;
}
form {
background-color: #fff;
padding: 20px;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
width: 100%;
max-width: 400px;
}
input[type="submit"] {
width: 100%;
padding: 10px;
background-color: #3F9FD8; /* Main color */
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 16px;
}
pubKey := &rsa.PublicKey{
N: new(big.Int).SetBytes(nBytes),
E: eInt,
}
input[type="submit"]:hover {
background-color: #357FAA; /* Darker shade of main color */
}
</style>
</head>
<body>
<div class="header">
<div class="dex">Dex</div>
<div class="example-app">Example App</div>
</div>
<form action="/login" method="post">
<div class="form-instructions">
If needed, customize your login settings below, then click <strong>Login</strong> to proceed.
</div>
<hr/>
<p>
<label for="cross_client">Authenticate for:</label>
<input type="text" id="cross_client" name="cross_client" placeholder="list of client-ids">
</p>
<p>
<label for="extra_scopes">Extra scopes:</label>
<input type="text" id="extra_scopes" name="extra_scopes" placeholder="list of scopes">
</p>
<p>
<label for="connector_id">Connector ID:</label>
<input type="text" id="connector_id" name="connector_id" placeholder="connector id">
</p>
<p>
<label for="offline_access">Request offline access:</label>
<input type="checkbox" id="offline_access" name="offline_access" value="yes" checked>
</p>
<p>
<input type="submit" value="Login">
</p>
</form>
</body>
</html>`))
pubKeyBytes, err := x509.MarshalPKIXPublicKey(pubKey)
if err != nil {
return ""
}
func renderIndex(w http.ResponseWriter) {
renderTemplate(w, indexTmpl, nil)
}
pubKeyPEM := pem.EncodeToMemory(&pem.Block{
Type: "PUBLIC KEY",
Bytes: pubKeyBytes,
})
type tokenTmplData struct {
IDToken string
AccessToken string
RefreshToken string
RedirectURL string
Claims string
return string(pubKeyPEM)
}
var tokenTmpl = template.Must(template.New("token.html").Parse(`<html>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Tokens</title>
<style>
` + css + `
body {
color: #333;
margin: 0;
padding: 20px;
position: relative;
}
input[type="submit"] {
margin-top: 10px;
padding: 8px 16px;
background-color: #3F9FD8; /* Main color */
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
transition: background-color 0.3s ease;
}
input[type="submit"]:hover {
background-color: #357FAA; /* Darker shade of main color */
}
</style>
</head>
<body>
{{ if .IDToken }}
<div class="token-block">
<div class="token-title">
ID Token:
<a href="#" onclick="window.open('https://jwt.io/#debugger-io?token=' + encodeURIComponent('{{ .IDToken }}'), '_blank')">Decode on jwt.io</a>
</div>
<pre><code class="token-code">{{ .IDToken }}</code></pre>
</div>
{{ end }}
{{ if .AccessToken }}
<div class="token-block">
<div class="token-title">
Access Token:
<a href="#" onclick="window.open('https://jwt.io/#debugger-io?token=' + encodeURIComponent('{{ .AccessToken }}'), '_blank')">Decode on jwt.io</a>
</div>
<pre><code class="token-code">{{ .AccessToken }}</code></pre>
</div>
{{ end }}
{{ if .Claims }}
<div class="token-block">
<div class="token-title">Claims:</div>
<pre><code id="claims">{{ .Claims }}</code></pre>
</div>
{{ end }}
{{ if .RefreshToken }}
<div class="token-block">
<div class="token-title">Refresh Token:</div>
<pre><code class="token-code">{{ .RefreshToken }}</code></pre>
<form action="{{ .RedirectURL }}" method="post">
<input type="hidden" name="refresh_token" value="{{ .RefreshToken }}">
<input type="submit" value="Redeem refresh token">
</form>
</div>
{{ end }}
<a href="/" class="back-button">Back to Home</a>
<script>
// Simple JSON syntax highlighter
document.addEventListener("DOMContentLoaded", function() {
const claimsElement = document.getElementById("claims");
if (claimsElement) {
try {
const json = JSON.parse(claimsElement.textContent);
claimsElement.innerHTML = syntaxHighlight(json);
} catch (e) {
console.error("Invalid JSON in claims:", e);
}
}
});
function syntaxHighlight(json) {
if (typeof json != 'string') {
json = JSON.stringify(json, undefined, 2);
}
json = json.replace(/&/g, '&amp;').replace(/</g, '<').replace(/>/g, '>');
return json.replace(/("(\\u[\da-fA-F]{4}|\\[^u]|[^\\"])*"(\s*:)?|\b(true|false|null)\b|\b\d+\b)/g, function (match) {
let cls = 'number';
if (/^"/.test(match)) {
if (/:$/.test(match)) {
cls = 'key';
} else {
cls = 'string';
}
} else if (/true|false/.test(match)) {
cls = 'boolean';
} else if (/null/.test(match)) {
cls = 'null';
}
return '<span class="' + cls + '">' + match + '</span>';
});
}
</script>
</body>
</html>
`))
func renderToken(w http.ResponseWriter, redirectURL, idToken, accessToken, refreshToken, claims string) {
renderTemplate(w, tokenTmpl, tokenTmplData{
IDToken: idToken,
AccessToken: accessToken,
RefreshToken: refreshToken,
RedirectURL: redirectURL,
Claims: claims,
})
func renderToken(w http.ResponseWriter, ctx context.Context, provider *oidc.Provider, redirectURL, idToken, accessToken, refreshToken, claims string) {
data := tokenTmplData{
IDToken: idToken,
IDTokenJWTLink: generateJWTIOLink(idToken, provider, ctx),
AccessToken: accessToken,
AccessTokenJWTLink: generateJWTIOLink(accessToken, provider, ctx),
RefreshToken: refreshToken,
RedirectURL: redirectURL,
Claims: claims,
PublicKeyPEM: getPublicKeyPEM(provider),
}
renderTemplate(w, tokenTmpl, data)
}
func renderTemplate(w http.ResponseWriter, tmpl *template.Template, data interface{}) {
@ -371,13 +163,9 @@ func renderTemplate(w http.ResponseWriter, tmpl *template.Template, data interfa
switch err := err.(type) {
case *template.Error:
// An ExecError guarantees that Execute has not written to the underlying reader.
log.Printf("Error rendering template %s: %s", tmpl.Name(), err)
// TODO(ericchiang): replace with better internal server error.
http.Error(w, "Internal server error", http.StatusInternalServerError)
default:
// An error with the underlying write, such as the connection being
// dropped. Ignore for now.
// An error with the underlying write, such as the connection being dropped. Ignore for now.
}
}

62
examples/example-app/templates/index.html

@ -0,0 +1,62 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Example App - Login</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<div class="container">
<img class="logo" src="{{.LogoURI}}" alt="Dex">
<form id="login-form" action="/login" method="post">
<div class="app-description">
This is an example application for <b>Dex</b> OpenID Connect provider.<br>
Learn more in the <a href="https://dexidp.io/docs/" target="_blank">documentation</a>.
</div>
<div class="primary-action">
<input type="submit" value="Login">
</div>
<details class="advanced">
<summary>Advanced options</summary>
<div class="field">
<label>Scopes:</label>
<small class="hint">Select OpenID Connect scopes to request. Standard scopes are pre-selected.</small>
<div class="scopes-list" id="scopes-list">
{{range .ScopesSupported}}
<div class="scope-item">
<input type="checkbox" name="extra_scopes" value="{{.}}" id="scope_{{.}}">
<label for="scope_{{.}}">{{.}}</label>
</div>
{{end}}
</div>
{{if eq (len .ScopesSupported) 0}}
<div class="hint">No scopes from discovery - add custom scopes below.</div>
{{end}}
<div class="inline-input">
<input type="text" id="custom_scope_input" placeholder="custom-scope">
<button type="button" id="add-custom-scope">Add scope</button>
</div>
</div>
<div class="field">
<label for="cross_client_input">Cross-Client auth:</label>
<small class="hint">Each client is sent as audience:server:client_id scope.</small>
<div id="cross-client-list"></div>
<div class="inline-input">
<input type="text" id="cross_client_input" placeholder="client-id">
<button type="button" id="add-cross-client">Add</button>
</div>
</div>
<div class="field">
<label for="connector_id">Connector:</label>
<small class="hint">Specify a connector ID to bypass the connector selection screen.</small>
<input type="text" id="connector_id" name="connector_id" placeholder="connector id">
</div>
</details>
</form>
</div>
<script src="/static/app.js"></script>
</body>
</html>

64
examples/example-app/templates/token.html

@ -0,0 +1,64 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Tokens</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body class="token-page">
{{ if .IDToken }}
<div class="token-block">
<div class="token-title">
ID Token:
<a href="{{.IDTokenJWTLink}}" target="_blank">Decode on jwt.io</a>
</div>
<pre><code class="token-code">{{ .IDToken }}</code></pre>
</div>
{{ end }}
{{ if .AccessToken }}
<div class="token-block">
<div class="token-title">
Access Token:
<a href="{{.AccessTokenJWTLink}}" target="_blank">Decode on jwt.io</a>
</div>
<pre><code class="token-code">{{ .AccessToken }}</code></pre>
</div>
{{ end }}
{{ if .Claims }}
<div class="token-block">
<div class="token-title">Claims:</div>
<pre><code id="claims">{{ .Claims }}</code></pre>
</div>
{{ end }}
{{ if .RefreshToken }}
<div class="token-block">
<div class="token-title">Refresh Token:</div>
<pre><code class="token-code">{{ .RefreshToken }}</code></pre>
<form action="{{ .RedirectURL }}" method="post">
<input type="hidden" name="refresh_token" value="{{ .RefreshToken }}">
<input type="submit" value="Redeem refresh token">
</form>
</div>
{{ end }}
{{ if .PublicKeyPEM }}
<div class="token-block">
<div class="token-title">
Public Key (for JWT verification):
<button type="button" class="copy-btn" onclick="copyPublicKey()">Copy to Clipboard</button>
</div>
<pre><code id="public-key">{{ .PublicKeyPEM }}</code></pre>
<div class="hint">Copy this key and paste it into jwt.io's "Verify Signature" section to validate the token signature.</div>
</div>
{{ end }}
<a href="/" class="back-button">Back to Home</a>
<script src="/static/token.js"></script>
</body>
</html>
Loading…
Cancel
Save