<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Register OAuth Client - Heaerie SSO</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Roboto', 'Arial', sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
padding: 20px;
display: flex;
justify-content: center;
align-items: center;
}
.container {
background: white;
border-radius: 12px;
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
padding: 40px;
width: 100%;
max-width: 700px;
}
.header {
text-align: center;
margin-bottom: 30px;
}
.logo {
font-size: 28px;
font-weight: 500;
color: #1a73e8;
margin-bottom: 10px;
}
.logo-heaerie {
color: #202124;
font-weight: 400;
}
.logo-sso {
color: #1a73e8;
font-weight: 500;
}
.title {
font-size: 24px;
color: #202124;
margin-bottom: 8px;
}
.subtitle {
font-size: 14px;
color: #5f6368;
}
.success-message {
background-color: #e8f5e9;
border: 1px solid #c3e6cb;
border-radius: 8px;
padding: 20px;
margin-bottom: 24px;
display: none;
}
.success-message.show {
display: block;
}
.success-title {
color: #1e8e3e;
font-weight: 600;
font-size: 16px;
margin-bottom: 12px;
}
.client-info {
background: #f8f9fa;
border-radius: 6px;
padding: 15px;
margin-top: 10px;
}
.client-info-item {
margin-bottom: 12px;
}
.client-info-label {
font-weight: 600;
color: #202124;
font-size: 13px;
margin-bottom: 4px;
}
.client-info-value {
background: white;
padding: 10px;
border-radius: 4px;
font-family: 'Courier New', monospace;
font-size: 13px;
color: #1a73e8;
word-break: break-all;
border: 1px solid #dadce0;
}
.copy-btn {
background: #1a73e8;
color: white;
border: none;
padding: 6px 12px;
border-radius: 4px;
font-size: 12px;
cursor: pointer;
margin-top: 8px;
}
.copy-btn:hover {
background: #1765cc;
}
.form-section {
margin-bottom: 24px;
}
.section-title {
font-size: 16px;
color: #202124;
font-weight: 600;
margin-bottom: 16px;
padding-bottom: 8px;
border-bottom: 2px solid #f0f0f0;
}
.form-group {
margin-bottom: 20px;
}
.form-group label {
display: block;
font-size: 14px;
color: #202124;
margin-bottom: 6px;
font-weight: 500;
}
.form-group label .required {
color: #d93025;
}
.form-group input,
.form-group select,
.form-group textarea {
width: 100%;
padding: 12px;
border: 1px solid #dadce0;
border-radius: 4px;
font-size: 14px;
color: #202124;
transition: border-color 0.2s;
font-family: inherit;
}
.form-group textarea {
resize: vertical;
min-height: 80px;
}
.form-group input:focus,
.form-group select:focus,
.form-group textarea:focus {
outline: none;
border-color: #1a73e8;
box-shadow: 0 0 0 2px rgba(26, 115, 232, 0.1);
}
.help-text {
font-size: 12px;
color: #5f6368;
margin-top: 4px;
}
.checkbox-group {
margin-bottom: 12px;
}
.checkbox-group label {
display: flex;
align-items: center;
font-weight: 400;
cursor: pointer;
}
.checkbox-group input[type="checkbox"] {
width: 18px;
height: 18px;
margin-right: 8px;
cursor: pointer;
accent-color: #1a73e8;
}
.radio-group {
display: flex;
gap: 24px;
margin-top: 8px;
}
.radio-option {
display: flex;
align-items: center;
}
.radio-option input[type="radio"] {
width: 18px;
height: 18px;
margin-right: 8px;
cursor: pointer;
accent-color: #1a73e8;
}
.radio-option label {
margin: 0;
cursor: pointer;
}
.btn-container {
display: flex;
gap: 12px;
margin-top: 32px;
justify-content: flex-end;
}
.btn {
padding: 12px 32px;
border: none;
border-radius: 4px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.btn-secondary {
background-color: white;
color: #1a73e8;
border: 1px solid #dadce0;
}
.btn-secondary:hover {
background-color: #f8f9fa;
}
.btn-primary {
background-color: #1a73e8;
color: white;
}
.btn-primary:hover {
background-color: #1765cc;
box-shadow: 0 2px 4px rgba(26,115,232,0.4);
}
.btn-primary:disabled {
background-color: #dadce0;
cursor: not-allowed;
}
.info-box {
background-color: #e8f4fd;
border-left: 4px solid #1a73e8;
padding: 16px;
border-radius: 4px;
margin-bottom: 24px;
}
.info-box-title {
font-weight: 600;
color: #1a73e8;
margin-bottom: 8px;
}
.info-box-text {
font-size: 13px;
color: #202124;
line-height: 1.5;
}
.error-message {
background-color: #fce8e6;
border: 1px solid #f5c6cb;
border-radius: 8px;
padding: 16px;
margin-bottom: 24px;
display: none;
color: #d93025;
}
.error-message.show {
display: block;
}
.uri-input-group {
position: relative;
}
.uri-list {
margin-top: 12px;
}
.uri-item {
background: #f8f9fa;
border: 1px solid #dadce0;
border-radius: 4px;
padding: 10px 12px;
margin-bottom: 8px;
display: flex;
justify-content: space-between;
align-items: center;
}
.uri-text {
font-size: 13px;
color: #202124;
word-break: break-all;
flex: 1;
}
.remove-uri-btn {
background: #d93025;
color: white;
border: none;
border-radius: 4px;
padding: 4px 12px;
font-size: 12px;
cursor: pointer;
margin-left: 12px;
white-space: nowrap;
}
.remove-uri-btn:hover {
background: #c5221f;
}
.add-uri-btn {
background: #f8f9fa;
color: #1a73e8;
border: 1px solid #dadce0;
border-radius: 4px;
padding: 8px 16px;
font-size: 13px;
cursor: pointer;
margin-top: 8px;
width: 100%;
}
.add-uri-btn:hover {
background: #e8f0fe;
}
.validation-error {
color: #d93025;
font-size: 12px;
margin-top: 4px;
display: none;
}
.validation-error.show {
display: block;
}
.char-counter {
font-size: 12px;
color: #5f6368;
text-align: right;
margin-top: 4px;
}
.example-link {
font-size: 12px;
color: #1a73e8;
cursor: pointer;
text-decoration: underline;
margin-left: 8px;
}
.example-link:hover {
color: #1765cc;
}
.token-lifetime-group {
display: flex;
gap: 12px;
align-items: flex-end;
}
.token-lifetime-group .form-group {
flex: 1;
margin-bottom: 0;
}
.advanced-section {
background: #f8f9fa;
border-radius: 8px;
padding: 20px;
margin-top: 16px;
}
.toggle-advanced {
background: none;
border: none;
color: #1a73e8;
font-size: 14px;
cursor: pointer;
display: flex;
align-items: center;
gap: 8px;
padding: 0;
font-weight: 500;
}
.toggle-advanced:hover {
text-decoration: underline;
}
.toggle-icon {
transition: transform 0.3s;
}
.toggle-icon.open {
transform: rotate(180deg);
}
.loading-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: none;
justify-content: center;
align-items: center;
z-index: 9999;
}
.loading-overlay.show {
display: flex;
}
.spinner {
border: 4px solid #f3f3f3;
border-top: 4px solid #1a73e8;
border-radius: 50%;
width: 50px;
height: 50px;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
@media (max-width: 640px) {
.container {
padding: 24px;
}
.btn-container {
flex-direction: column-reverse;
}
.btn {
width: 100%;
}
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<div class="logo">
<span class="logo-heaerie">heaerie</span>
<span class="logo-sso"> sso</span>
</div>
<h1 class="title">Register OAuth 2.1 Client</h1>
<p class="subtitle">Create a new OAuth client application</p>
</div>
<div class="error-message" id="errorMessage">
<strong>Registration Failed</strong>
<div id="errorText" style="margin-top: 8px;"></div>
</div>
<div class="success-message" id="successMessage">
<div class="success-title">✓ Client Registered Successfully!</div>
<div class="client-info">
<div class="client-info-item">
<div class="client-info-label">Client ID</div>
<div class="client-info-value" id="generatedClientId">client-id-here</div>
<button class="copy-btn" onclick="copyToClipboard('generatedClientId')">Copy</button>
</div>
<div class="client-info-item" id="clientSecretContainer" style="display:none;">
<div class="client-info-label">Client Secret</div>
<div class="client-info-value" id="generatedClientSecret">client-secret-here</div>
<button class="copy-btn" onclick="copyToClipboard('generatedClientSecret')">Copy</button>
<div class="help-text" style="margin-top: 8px; color: #d93025;">
⚠️ Save this secret securely. It won't be shown again.
</div>
</div>
<div class="client-info-item">
<div class="client-info-label">Configuration Summary</div>
<div id="configSummary" style="margin-top: 8px; font-size: 13px; line-height: 1.8;">
<!-- Will be populated dynamically -->
</div>
</div>
</div>
<div class="btn-container" style="margin-top: 24px;">
<button class="btn btn-secondary" onclick="downloadConfig()">
📥 Download Config
</button>
<button class="btn btn-primary" onclick="location.reload()">
Register Another Client
</button>
</div>
</div>
<div class="info-box">
<div class="info-box-title">📋 Before You Begin</div>
<div class="info-box-text">
Configure your OAuth 2.1 client settings below. Public clients (SPAs, mobile apps) don't require a client secret.
Confidential clients (server-side apps) will receive a client secret after registration.
</div>
</div>
<form id="registerForm">
<div class="form-section">
<div class="section-title">Basic Information</div>
<div class="form-group">
<label for="clientName">
Client Name <span class="required">*</span>
</label>
<input
type="text"
id="clientName"
name="clientName"
placeholder="My Application"
required
autofocus
maxlength="100"
oninput="updateCharCounter('clientName', 100)"
/>
<div class="help-text">
A human-readable name for your application
<span class="example-link" onclick="fillExample('spa')">SPA Example</span>
<span class="example-link" onclick="fillExample('mobile')">Mobile Example</span>
<span class="example-link" onclick="fillExample('server')">Server Example</span>
</div>
<div class="char-counter" id="clientNameCounter">0 / 100</div>
<div class="validation-error" id="clientNameError">Please enter a valid client name</div>
</div>
<div class="form-group">
<label for="clientType">
Client Type <span class="required">*</span>
</label>
<div class="radio-group">
<div class="radio-option">
<input
type="radio"
id="publicClient"
name="clientType"
value="public"
checked
onchange="updateClientTypeFields()"
/>
<label for="publicClient">Public Client (SPA, Mobile)</label>
</div>
<div class="radio-option">
<input
type="radio"
id="confidentialClient"
name="clientType"
value="confidential"
onchange="updateClientTypeFields()"
/>
<label for="confidentialClient">Confidential Client (Server)</label>
</div>
</div>
<div class="help-text">Public clients cannot securely store secrets</div>
</div>
<div class="form-group">
<label for="description">
Description
</label>
<textarea
id="description"
name="description"
placeholder="Brief description of your application"
></textarea>
</div>
</div>
<div class="form-section">
<div class="section-title">OAuth Configuration</div>
<div class="form-group">
<label for="redirectUris">
Redirect URIs <span class="required">*</span>
</label>
<div class="uri-input-group">
<input
type="url"
id="redirectUriInput"
placeholder="http://localhost:3000/callback"
onkeypress="handleUriKeyPress(event, 'redirect')"
/>
<button type="button" class="add-uri-btn" onclick="addUri('redirect')">
+ Add Redirect URI
</button>
</div>
<div class="uri-list" id="redirectUriList"></div>
<div class="help-text">Where users will be redirected after authorization</div>
<div class="validation-error" id="redirectUriError">Please add at least one valid redirect URI</div>
</div>
<div class="form-group">
<label for="logoutRedirectUris">
Post-Logout Redirect URIs
</label>
<div class="uri-input-group">
<input
type="url"
id="logoutRedirectUriInput"
placeholder="http://localhost:3000/"
onkeypress="handleUriKeyPress(event, 'logout')"
/>
<button type="button" class="add-uri-btn" onclick="addUri('logout')">
+ Add Logout URI
</button>
</div>
<div class="uri-list" id="logoutRedirectUriList"></div>
<div class="help-text">Where users will be redirected after logout (optional)</div>
</div>
<div class="form-group">
<label>Grant Types <span class="required">*</span></label>
<div class="checkbox-group">
<label>
<input type="checkbox" name="grantTypes" value="authorization_code" checked />
<span>Authorization Code (Recommended)</span>
</label>
</div>
<div class="checkbox-group">
<label>
<input type="checkbox" name="grantTypes" value="refresh_token" checked />
<span>Refresh Token</span>
</label>
</div>
<div class="checkbox-group" id="clientCredentialsOption" style="display:none;">
<label>
<input type="checkbox" name="grantTypes" value="client_credentials" />
<span>Client Credentials (Server-to-Server)</span>
</label>
</div>
</div>
<div class="form-group">
<label>Scopes <span class="required">*</span></label>
<div class="checkbox-group">
<label>
<input type="checkbox" name="scopes" value="profile" checked />
<span>profile</span>
</label>
</div>
<div class="checkbox-group">
<label>
<input type="checkbox" name="scopes" value="email" />
<span>email</span>
</label>
</div>
<div class="checkbox-group">
<label>
<input type="checkbox" name="scopes" value="read" checked />
<span>read</span>
</label>
</div>
<div class="checkbox-group">
<label>
<input type="checkbox" name="scopes" value="write" />
<span>write</span>
</label>
</div>
</div>
</div>
<div class="form-section">
<div class="section-title">Security Settings</div>
<div class="form-group">
<label>
<input type="checkbox" name="requireConsent" id="requireConsent" checked />
Require User Consent
</label>
<div class="help-text">Users must approve access to their data</div>
</div>
<div class="form-group">
<label>
<input type="checkbox" name="requirePkce" id="requirePkce" checked />
Require PKCE (OAuth 2.1)
</label>
<div class="help-text">Proof Key for Code Exchange - highly recommended for security</div>
</div>
<button type="button" class="toggle-advanced" onclick="toggleAdvanced()">
<span class="toggle-icon" id="toggleIcon">▼</span>
Advanced Settings
</button>
<div class="advanced-section" id="advancedSection" style="display: none;">
<div class="form-group">
<label for="accessTokenValidity">Access Token Lifetime</label>
<div class="token-lifetime-group">
<div class="form-group">
<input
type="number"
id="accessTokenValidity"
name="accessTokenValidity"
value="3600"
min="300"
max="86400"
/>
<div class="help-text">Seconds (default: 3600 = 1 hour)</div>
</div>
</div>
</div>
<div class="form-group">
<label for="refreshTokenValidity">Refresh Token Lifetime</label>
<div class="token-lifetime-group">
<div class="form-group">
<input
type="number"
id="refreshTokenValidity"
name="refreshTokenValidity"
value="2592000"
min="3600"
max="31536000"
/>
<div class="help-text">Seconds (default: 2592000 = 30 days)</div>
</div>
</div>
</div>
<div class="form-group">
<label>
<input type="checkbox" name="reuseRefreshTokens" id="reuseRefreshTokens" />
Reuse Refresh Tokens
</label>
<div class="help-text">If unchecked, refresh tokens are rotated (OAuth 2.1 recommended)</div>
</div>
</div>
</div>
<div class="btn-container">
<button type="button" class="btn btn-secondary" onclick="window.location.href='/'">
Cancel
</button>
<button type="submit" class="btn btn-primary">
Register Client
</button>
</div>
</form>
</div>
<div class="loading-overlay" id="loadingOverlay">
<div class="spinner"></div>
</div>
<script>
// Store URIs
const redirectUris = [];
const logoutUris = [];
function updateClientTypeFields() {
const isConfidential = document.getElementById('confidentialClient').checked;
document.getElementById('clientCredentialsOption').style.display =
isConfidential ? 'block' : 'none';
}
function updateCharCounter(fieldId, maxLength) {
const field = document.getElementById(fieldId);
const counter = document.getElementById(fieldId + 'Counter');
if (counter) {
counter.textContent = `${field.value.length} / ${maxLength}`;
}
}
function toggleAdvanced() {
const section = document.getElementById('advancedSection');
const icon = document.getElementById('toggleIcon');
if (section.style.display === 'none') {
section.style.display = 'block';
icon.classList.add('open');
} else {
section.style.display = 'none';
icon.classList.remove('open');
}
}
function handleUriKeyPress(event, type) {
if (event.key === 'Enter') {
event.preventDefault();
addUri(type);
}
}
function addUri(type) {
const inputId = type === 'redirect' ? 'redirectUriInput' : 'logoutRedirectUriInput';
const input = document.getElementById(inputId);
const uri = input.value.trim();
if (!uri) return;
// Validate URI
try {
new URL(uri);
} catch (e) {
alert('Please enter a valid URL');
return;
}
const uriArray = type === 'redirect' ? redirectUris : logoutUris;
if (uriArray.includes(uri)) {
alert('This URI has already been added');
return;
}
uriArray.push(uri);
input.value = '';
renderUris(type);
}
function removeUri(type, index) {
const uriArray = type === 'redirect' ? redirectUris : logoutUris;
uriArray.splice(index, 1);
renderUris(type);
}
function renderUris(type) {
const listId = type === 'redirect' ? 'redirectUriList' : 'logoutRedirectUriList';
const list = document.getElementById(listId);
const uriArray = type === 'redirect' ? redirectUris : logoutUris;
list.innerHTML = uriArray.map((uri, index) => `
<div class="uri-item">
<span class="uri-text">${uri}</span>
<button type="button" class="remove-uri-btn" onclick="removeUri('${type}', ${index})">
Remove
</button>
</div>
`).join('');
}
function fillExample(type) {
const examples = {
spa: {
name: 'My React App',
type: 'public',
description: 'Single Page Application built with React',
redirectUris: ['http://localhost:3000/callback', 'https://myapp.com/callback'],
logoutUris: ['http://localhost:3000/', 'https://myapp.com/'],
scopes: ['profile', 'read']
},
mobile: {
name: 'My Mobile App',
type: 'public',
description: 'Native mobile application for iOS and Android',
redirectUris: ['myapp://callback', 'https://myapp.com/mobile/callback'],
logoutUris: ['myapp://logout'],
scopes: ['profile', 'read', 'write']
},
server: {
name: 'My Server App',
type: 'confidential',
description: 'Server-side application with secure backend',
redirectUris: ['https://myapp.com/oauth/callback'],
logoutUris: ['https://myapp.com/'],
scopes: ['profile', 'read', 'write']
}
};
const example = examples[type];
document.getElementById('clientName').value = example.name;
document.getElementById('description').value = example.description;
if (example.type === 'public') {
document.getElementById('publicClient').checked = true;
} else {
document.getElementById('confidentialClient').checked = true;
}
updateClientTypeFields();
// Clear and add URIs
redirectUris.length = 0;
logoutUris.length = 0;
redirectUris.push(...example.redirectUris);
logoutUris.push(...example.logoutUris);
renderUris('redirect');
renderUris('logout');
// Check scopes
document.querySelectorAll('input[name="scopes"]').forEach(input => {
input.checked = example.scopes.includes(input.value);
});
updateCharCounter('clientName', 100);
}
function copyToClipboard(elementId) {
const element = document.getElementById(elementId);
const text = element.textContent;
navigator.clipboard.writeText(text).then(() => {
const btn = element.nextElementSibling;
const originalText = btn.textContent;
btn.textContent = '✓ Copied!';
setTimeout(() => {
btn.textContent = originalText;
}, 2000);
});
}
function validateForm() {
let isValid = true;
// Validate client name
const clientName = document.getElementById('clientName').value.trim();
if (!clientName) {
document.getElementById('clientNameError').classList.add('show');
isValid = false;
} else {
document.getElementById('clientNameError').classList.remove('show');
}
// Validate redirect URIs
if (redirectUris.length === 0) {
document.getElementById('redirectUriError').classList.add('show');
isValid = false;
} else {
document.getElementById('redirectUriError').classList.remove('show');
}
return isValid;
}
function downloadConfig() {
const config = {
clientId: document.getElementById('generatedClientId').textContent,
clientSecret: document.getElementById('generatedClientSecret').textContent || null,
clientType: document.querySelector('input[name="clientType"]:checked').value,
redirectUris: redirectUris,
logoutRedirectUris: logoutUris,
authorizationEndpoint: window.location.origin + '/oauth2/authorize',
tokenEndpoint: window.location.origin + '/oauth2/token',
userInfoEndpoint: window.location.origin + '/userinfo',
jwksUri: window.location.origin + '/oauth2/jwks',
issuer: window.location.origin
};
const blob = new Blob([JSON.stringify(config, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `oauth-client-${config.clientId}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
// Handle form submission
document.getElementById('registerForm').addEventListener('submit', async (e) => {
e.preventDefault();
if (!validateForm()) {
return;
}
document.getElementById('loadingOverlay').classList.add('show');
document.getElementById('errorMessage').classList.remove('show');
const formData = new FormData(e.target);
const data = {
clientName: formData.get('clientName'),
clientType: formData.get('clientType'),
description: formData.get('description'),
redirectUris: redirectUris,
logoutRedirectUris: logoutUris,
grantTypes: formData.getAll('grantTypes'),
scopes: formData.getAll('scopes'),
requireConsent: formData.get('requireConsent') === 'on',
requirePkce: formData.get('requirePkce') === 'on',
accessTokenValidity: parseInt(formData.get('accessTokenValidity')) || 3600,
refreshTokenValidity: parseInt(formData.get('refreshTokenValidity')) || 2592000,
reuseRefreshTokens: formData.get('reuseRefreshTokens') === 'on'
};
try {
const response = await fetch('/oauth/register', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(data)
});
document.getElementById('loadingOverlay').classList.remove('show');
if (response.ok) {
const result = await response.json();
// Show success message
document.getElementById('generatedClientId').textContent = result.clientId;
if (result.clientSecret) {
document.getElementById('generatedClientSecret').textContent = result.clientSecret;
document.getElementById('clientSecretContainer').style.display = 'block';
} else {
document.getElementById('clientSecretContainer').style.display = 'none';
}
// Build configuration summary
const summary = `
<div><strong>Type:</strong> ${data.clientType === 'public' ? 'Public Client' : 'Confidential Client'}</div>
<div><strong>Grant Types:</strong> ${data.grantTypes.join(', ')}</div>
<div><strong>Scopes:</strong> ${data.scopes.join(', ')}</div>
<div><strong>PKCE Required:</strong> ${data.requirePkce ? 'Yes' : 'No'}</div>
<div><strong>User Consent:</strong> ${data.requireConsent ? 'Required' : 'Not Required'}</div>
<div><strong>Redirect URIs:</strong> ${redirectUris.length}</div>
`;
document.getElementById('configSummary').innerHTML = summary;
document.getElementById('successMessage').classList.add('show');
document.getElementById('registerForm').style.display = 'none';
document.querySelector('.info-box').style.display = 'none';
// Scroll to top
window.scrollTo({ top: 0, behavior: 'smooth' });
} else {
const error = await response.text();
document.getElementById('errorText').textContent = error || 'Registration failed. Please try again.';
document.getElementById('errorMessage').classList.add('show');
window.scrollTo({ top: 0, behavior: 'smooth' });
}
} catch (error) {
console.error('Error:', error);
document.getElementById('loadingOverlay').classList.remove('show');
document.getElementById('errorText').textContent = 'An error occurred. Please check your connection and try again.';
document.getElementById('errorMessage').classList.add('show');
window.scrollTo({ top: 0, behavior: 'smooth' });
}
});
// Initialize
updateClientTypeFields();
</script>
</body>
</html>