Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/idp/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -296,7 +296,7 @@ export async function idpPlugin(fastify, options) {
});
} else {
fastify.get('/idp/register', async (request, reply) => {
return handleRegisterGet(request, reply, inviteOnly);
return handleRegisterGet(request, reply, issuer, inviteOnly);
});

// Registration - rate limited to prevent spam accounts
Expand Down
61 changes: 46 additions & 15 deletions src/idp/interactions.js
Original file line number Diff line number Diff line change
Expand Up @@ -329,9 +329,21 @@ export async function handleAbort(request, reply, provider) {
* Handle GET /idp/register
* Shows registration page
*/
export async function handleRegisterGet(request, reply, inviteOnly = false) {
export async function handleRegisterGet(request, reply, issuer, inviteOnly = false) {
const uid = request.query.uid || null;
return reply.type('text/html').send(registerPage(uid, null, null, inviteOnly));
const ctx = previewContext(request, issuer);
return reply.type('text/html').send(registerPage(uid, null, null, inviteOnly, ctx));
}

// Live-preview context for the register page: lets the client-side script
// build the WebID + storage URL the user is about to claim, before submit.
function previewContext(request, issuer) {
const baseUri = (issuer || `${request.protocol}://${request.hostname}`).replace(/\/$/, '');
return {
baseUri,
subdomainsEnabled: !!request.subdomainsEnabled,
baseDomain: request.baseDomain || null,
};
}

/**
Expand All @@ -340,6 +352,7 @@ export async function handleRegisterGet(request, reply, inviteOnly = false) {
*/
export async function handleRegisterPost(request, reply, issuer, inviteOnly = false) {
const uid = request.query.uid || null;
const ctx = previewContext(request, issuer);

// Parse body
let parsedBody = request.body || {};
Expand All @@ -348,7 +361,7 @@ export async function handleRegisterPost(request, reply, issuer, inviteOnly = fa
if (Buffer.isBuffer(parsedBody)) {
// Security: check body size
if (parsedBody.length > MAX_BODY_SIZE) {
return reply.code(413).type('text/html').send(registerPage(null, 'Request body exceeds maximum size.', null, inviteOnly));
return reply.code(413).type('text/html').send(registerPage(null, 'Request body exceeds maximum size.', null, inviteOnly, ctx));
}
const bodyStr = parsedBody.toString();
if (contentType.includes('application/json')) {
Expand All @@ -364,7 +377,7 @@ export async function handleRegisterPost(request, reply, issuer, inviteOnly = fa
} else if (typeof parsedBody === 'string') {
// Security: check body size
if (parsedBody.length > MAX_BODY_SIZE) {
return reply.code(413).type('text/html').send(registerPage(null, 'Request body exceeds maximum size.', null, inviteOnly));
return reply.code(413).type('text/html').send(registerPage(null, 'Request body exceeds maximum size.', null, inviteOnly, ctx));
}
const params = new URLSearchParams(parsedBody);
parsedBody = Object.fromEntries(params.entries());
Expand All @@ -376,32 +389,50 @@ export async function handleRegisterPost(request, reply, issuer, inviteOnly = fa
if (inviteOnly) {
const inviteResult = await validateInvite(invite);
if (!inviteResult.valid) {
return reply.code(403).type('text/html').send(registerPage(uid, inviteResult.error, null, inviteOnly));
return reply.code(403).type('text/html').send(registerPage(uid, inviteResult.error, null, inviteOnly, ctx));
}
}

// Validate input
if (!username || !password) {
return reply.type('text/html').send(registerPage(uid, 'Username and password are required', null, inviteOnly));
return reply.type('text/html').send(registerPage(uid, 'Username and password are required', null, inviteOnly, ctx));
}

// Validate username format
const usernameRegex = /^[a-z0-9]+$/;
// Validate username format. Must start and end alphanumeric; the middle
// can contain dot, dash, underscore — covers `alice-smith`, `alice.smith`,
// `alice_work`, and so on. No leading/trailing separators (avoids the
// `.hidden` / trailing-dot footguns), no `..` (path traversal hygiene
// even though storage already guards against it).
//
// In subdomain mode the username becomes a single-level subdomain — DNS
// hostnames don't allow `.` or `_`, and `server.js` already refuses to
// route multi-level subdomains as pods. So we restrict to alphanumeric +
// hyphen there to keep the username and the pod actually addressable.
const subdomainMode = !!(request.subdomainsEnabled && request.baseDomain);
const usernameRegex = subdomainMode
? /^[a-z0-9]([a-z0-9-]{1,30}[a-z0-9])?$/
: /^[a-z0-9]([a-z0-9._-]{1,30}[a-z0-9])?$/;
if (!usernameRegex.test(username)) {
return reply.type('text/html').send(registerPage(uid, 'Username must contain only lowercase letters and numbers', null, inviteOnly));
const msg = subdomainMode
? 'Username must be lowercase letters, numbers, or - (subdomain mode disallows . and _)'
: 'Username must be lowercase letters, numbers, or . _ - (start and end alphanumeric)';
return reply.type('text/html').send(registerPage(uid, msg, null, inviteOnly, ctx));
}
if (username.includes('..')) {
return reply.type('text/html').send(registerPage(uid, 'Username cannot contain ".."', null, inviteOnly, ctx));
}

if (username.length < 3) {
return reply.type('text/html').send(registerPage(uid, 'Username must be at least 3 characters', null, inviteOnly));
return reply.type('text/html').send(registerPage(uid, 'Username must be at least 3 characters', null, inviteOnly, ctx));
}

// Password strength validation
if (password.length < 8) {
return reply.type('text/html').send(registerPage(uid, 'Password must be at least 8 characters', null, inviteOnly));
return reply.type('text/html').send(registerPage(uid, 'Password must be at least 8 characters', null, inviteOnly, ctx));
}

if (password !== confirmPassword) {
return reply.type('text/html').send(registerPage(uid, 'Passwords do not match', null, inviteOnly));
return reply.type('text/html').send(registerPage(uid, 'Passwords do not match', null, inviteOnly, ctx));
}

try {
Expand All @@ -425,7 +456,7 @@ export async function handleRegisterPost(request, reply, issuer, inviteOnly = fa
const podPath = `${username}/`;
const podExists = await storage.exists(podPath);
if (podExists) {
return reply.type('text/html').send(registerPage(uid, 'Username is already taken', null, inviteOnly));
return reply.type('text/html').send(registerPage(uid, 'Username is already taken', null, inviteOnly, ctx));
}

// Create pod structure
Expand All @@ -445,11 +476,11 @@ export async function handleRegisterPost(request, reply, issuer, inviteOnly = fa
if (uid) {
return reply.redirect(`/idp/interaction/${uid}`);
} else {
return reply.type('text/html').send(registerPage(null, null, `Account created! You can now sign in as "${username}".`, inviteOnly));
return reply.type('text/html').send(registerPage(null, null, `Account created! You can now sign in as "${username}".`, inviteOnly, ctx));
}
} catch (err) {
request.log.error(err, 'Registration error');
return reply.type('text/html').send(registerPage(uid, err.message, null, inviteOnly));
return reply.type('text/html').send(registerPage(uid, err.message, null, inviteOnly, ctx));
}
}

Expand Down
110 changes: 102 additions & 8 deletions src/idp/views.js
Original file line number Diff line number Diff line change
Expand Up @@ -553,27 +553,75 @@ export function errorPage(title, message) {
/**
* Registration page HTML
*/
export function registerPage(uid = null, error = null, success = null, inviteOnly = false) {
export function registerPage(uid = null, error = null, success = null, inviteOnly = false, ctx = {}) {
const inviteField = inviteOnly ? `
<label for="invite">Invite Code</label>
<input type="text" id="invite" name="invite" required
placeholder="Enter your invite code" style="text-transform: uppercase;">
` : '';

// Embed the values the live preview needs. Escape characters that are
// unsafe in inline <script> contexts so values like "</script>" or
// U+2028 / U+2029 line separators can't terminate the script tag or
// confuse the parser when template-substituted.
const previewConfig = JSON.stringify({
baseUri: ctx.baseUri || '',
subdomainsEnabled: !!ctx.subdomainsEnabled,
baseDomain: ctx.baseDomain || '',
})
.replace(/</g, '\\u003c')
.replace(/\u2028/g, '\\u2028')
.replace(/\u2029/g, '\\u2029');

// Server validates more strictly than the HTML pattern can express; mirror
// as much as possible client-side so the browser catches obvious mistakes
// before submit. Subdomain mode drops dot/underscore (DNS hostname rules).
const usernamePattern = (ctx.subdomainsEnabled && ctx.baseDomain)
? '[a-z0-9](?:[a-z0-9-]{1,30}[a-z0-9])?'
: '(?!.*\\.\\.)[a-z0-9](?:[a-z0-9._-]{1,30}[a-z0-9])?';
const usernameTitle = (ctx.subdomainsEnabled && ctx.baseDomain)
? 'Lowercase letters, numbers, or - (start and end alphanumeric, 3–32 chars). Subdomain mode disallows . and _.'
: 'Lowercase letters, numbers, or . _ - (start and end alphanumeric, 3–32 chars, no consecutive dots)';

return `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Register - Solid IdP</title>
<style>${styles}</style>
<style>${styles}
/* registerPage local polish (#284) */
.container.register { padding-top: 32px; }
.register-header {
margin: -40px -40px 24px;
padding: 28px 40px 22px;
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
color: #fff;
border-radius: 12px 12px 0 0;
}
.register-header h1 { color: #fff; margin: 0 0 4px; font-size: 22px; }
.register-header .subtitle { color: rgba(255,255,255,.85); margin: 0; font-size: 13px; }
.preview {
margin: 4px 0 18px;
padding: 12px 14px;
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 8px;
font: 12px/1.55 ui-monospace, SFMono-Regular, Menlo, monospace;
color: #475569;
word-break: break-all;
}
.preview .label { color: #64748b; font-weight: 600; margin-right: 6px; }
.preview .placeholder { color: #94a3b8; font-style: italic; }
</style>
</head>
<body>
<div class="container">
<div class="logo">${solidLogo}</div>
<h1>Create Account</h1>
<p class="subtitle">Register for a new Solid Pod${inviteOnly ? ' (invite required)' : ''}</p>
<div class="container register">
<div class="register-header">
<h1>Create Account</h1>
<p class="subtitle">Register for a new Solid Pod${inviteOnly ? ' (invite required)' : ''}</p>
</div>

${error ? `<div class="error">${escapeHtml(error)}</div>` : ''}
${success ? `<div class="error" style="background: #efe; border-color: #cfc; color: #060;">${escapeHtml(success)}</div>` : ''}
Expand All @@ -583,8 +631,14 @@ export function registerPage(uid = null, error = null, success = null, inviteOnl

<label for="username">Username</label>
<input type="text" id="username" name="username" required ${!inviteOnly ? 'autofocus' : ''}
placeholder="Choose a username" pattern="[a-z0-9]+"
title="Lowercase letters and numbers only">
placeholder="Choose a username" minlength="3" maxlength="32"
pattern="${usernamePattern}"
title="${usernameTitle}">

<div class="preview" id="preview" aria-live="polite">
<div><span class="label">WebID</span><span id="preview-webid" class="placeholder">choose a username to preview</span></div>
<div style="margin-top: 4px;"><span class="label">Storage</span><span id="preview-storage" class="placeholder">—</span></div>
</div>

<label for="password">Password</label>
<input type="password" id="password" name="password" required
Expand All @@ -601,6 +655,46 @@ export function registerPage(uid = null, error = null, success = null, inviteOnl
Already have an account? <a href="${uid ? `/idp/interaction/${uid}` : '/idp/auth'}" style="color: #0066cc;">Sign In</a>
</p>
</div>

<script>
(function () {
var cfg = ${previewConfig};
var input = document.getElementById('username');
var webEl = document.getElementById('preview-webid');
var storEl = document.getElementById('preview-storage');
if (!input || !webEl || !storEl) return;

function render() {
// Server rejects uppercase outright, so normalise the field as the
// user types — keeps the preview honest and avoids a confusing
// post-submit error.
var normalised = (input.value || '').toLowerCase();
if (input.value !== normalised) input.value = normalised;
var u = normalised.trim();
if (!u) {
webEl.textContent = 'choose a username to preview';
webEl.className = 'placeholder';
storEl.textContent = '—';
storEl.className = 'placeholder';
return;
}
var pod, webid;
if (cfg.subdomainsEnabled && cfg.baseDomain) {
var origin = cfg.baseUri.split('://')[0] + '://';
pod = origin + u + '.' + cfg.baseDomain + '/';
} else {
pod = (cfg.baseUri || (location.protocol + '//' + location.host)) + '/' + u + '/';
}
webid = pod + 'profile/card.jsonld#me';
webEl.textContent = webid;
webEl.className = '';
storEl.textContent = pod;
storEl.className = '';
}
input.addEventListener('input', render);
render();
})();
</script>
</body>
</html>
`;
Expand Down
Loading