javascript-solid-server 0.0.139 → 0.0.141
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/idp/index.js +35 -2
- package/src/idp/interactions.js +46 -15
- package/src/idp/views.js +176 -9
- package/src/server.js +2 -0
- package/test/idp.test.js +171 -0
package/package.json
CHANGED
package/src/idp/index.js
CHANGED
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
} from './credentials.js';
|
|
25
25
|
import * as passkey from './passkey.js';
|
|
26
26
|
import { addTrustedIssuer } from '../auth/solid-oidc.js';
|
|
27
|
+
import { landingPage } from './views.js';
|
|
27
28
|
|
|
28
29
|
/**
|
|
29
30
|
* IdP Fastify Plugin
|
|
@@ -140,7 +141,7 @@ export async function idpPlugin(fastify, options) {
|
|
|
140
141
|
|
|
141
142
|
// Catch-all route for oidc-provider paths
|
|
142
143
|
// Must be registered BEFORE specific routes to be matched as fallback
|
|
143
|
-
const oidcPaths = ['/idp/
|
|
144
|
+
const oidcPaths = ['/idp/token', '/idp/reg', '/idp/me', '/idp/session', '/idp/session/*'];
|
|
144
145
|
|
|
145
146
|
for (const path of oidcPaths) {
|
|
146
147
|
fastify.route({
|
|
@@ -150,9 +151,41 @@ export async function idpPlugin(fastify, options) {
|
|
|
150
151
|
});
|
|
151
152
|
}
|
|
152
153
|
|
|
154
|
+
// /idp/auth: guard against the human-fat-fingered case where someone opens
|
|
155
|
+
// the URL directly with no `client_id`. oidc-provider would otherwise
|
|
156
|
+
// return a raw `invalid_request` error page; we 302 to the friendly
|
|
157
|
+
// landing instead. Real OIDC requests (with client_id) pass through.
|
|
158
|
+
fastify.route({
|
|
159
|
+
// HEAD is listed explicitly so the route's contract doesn't depend on
|
|
160
|
+
// Fastify's auto-HEAD-from-GET (which `exposeHeadRoutes` can disable);
|
|
161
|
+
// the handler below treats HEAD the same as GET for the bare-client_id
|
|
162
|
+
// redirect.
|
|
163
|
+
method: ['GET', 'HEAD', 'POST', 'DELETE', 'OPTIONS'],
|
|
164
|
+
url: '/idp/auth',
|
|
165
|
+
handler: async (request, reply) => {
|
|
166
|
+
// Only catch the truly-bare case (no `client_id` param at all). An
|
|
167
|
+
// explicit empty string is a malformed OIDC request — let
|
|
168
|
+
// oidc-provider surface the spec error instead of redirecting.
|
|
169
|
+
if (
|
|
170
|
+
(request.method === 'GET' || request.method === 'HEAD') &&
|
|
171
|
+
request.query?.client_id === undefined
|
|
172
|
+
) {
|
|
173
|
+
return reply.redirect('/idp');
|
|
174
|
+
}
|
|
175
|
+
return forwardToProvider(request, reply);
|
|
176
|
+
},
|
|
177
|
+
});
|
|
178
|
+
|
|
153
179
|
// Also handle /idp/auth/:uid for continued authorization after login
|
|
154
180
|
fastify.get('/idp/auth/:uid', forwardToProvider);
|
|
155
181
|
|
|
182
|
+
// Friendly landing — Create Account button + sign-in note.
|
|
183
|
+
// Pairs with the /idp/auth guard above so a human visitor lands here
|
|
184
|
+
// rather than on a raw OIDC error.
|
|
185
|
+
fastify.get('/idp', async (request, reply) => {
|
|
186
|
+
return reply.type('text/html').send(landingPage({ baseUri: issuer }));
|
|
187
|
+
});
|
|
188
|
+
|
|
156
189
|
// Token sub-paths
|
|
157
190
|
fastify.route({
|
|
158
191
|
method: ['GET', 'POST'],
|
|
@@ -296,7 +329,7 @@ export async function idpPlugin(fastify, options) {
|
|
|
296
329
|
});
|
|
297
330
|
} else {
|
|
298
331
|
fastify.get('/idp/register', async (request, reply) => {
|
|
299
|
-
return handleRegisterGet(request, reply, inviteOnly);
|
|
332
|
+
return handleRegisterGet(request, reply, issuer, inviteOnly);
|
|
300
333
|
});
|
|
301
334
|
|
|
302
335
|
// Registration - rate limited to prevent spam accounts
|
package/src/idp/interactions.js
CHANGED
|
@@ -329,9 +329,21 @@ export async function handleAbort(request, reply, provider) {
|
|
|
329
329
|
* Handle GET /idp/register
|
|
330
330
|
* Shows registration page
|
|
331
331
|
*/
|
|
332
|
-
export async function handleRegisterGet(request, reply, inviteOnly = false) {
|
|
332
|
+
export async function handleRegisterGet(request, reply, issuer, inviteOnly = false) {
|
|
333
333
|
const uid = request.query.uid || null;
|
|
334
|
-
|
|
334
|
+
const ctx = previewContext(request, issuer);
|
|
335
|
+
return reply.type('text/html').send(registerPage(uid, null, null, inviteOnly, ctx));
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// Live-preview context for the register page: lets the client-side script
|
|
339
|
+
// build the WebID + storage URL the user is about to claim, before submit.
|
|
340
|
+
function previewContext(request, issuer) {
|
|
341
|
+
const baseUri = (issuer || `${request.protocol}://${request.hostname}`).replace(/\/$/, '');
|
|
342
|
+
return {
|
|
343
|
+
baseUri,
|
|
344
|
+
subdomainsEnabled: !!request.subdomainsEnabled,
|
|
345
|
+
baseDomain: request.baseDomain || null,
|
|
346
|
+
};
|
|
335
347
|
}
|
|
336
348
|
|
|
337
349
|
/**
|
|
@@ -340,6 +352,7 @@ export async function handleRegisterGet(request, reply, inviteOnly = false) {
|
|
|
340
352
|
*/
|
|
341
353
|
export async function handleRegisterPost(request, reply, issuer, inviteOnly = false) {
|
|
342
354
|
const uid = request.query.uid || null;
|
|
355
|
+
const ctx = previewContext(request, issuer);
|
|
343
356
|
|
|
344
357
|
// Parse body
|
|
345
358
|
let parsedBody = request.body || {};
|
|
@@ -348,7 +361,7 @@ export async function handleRegisterPost(request, reply, issuer, inviteOnly = fa
|
|
|
348
361
|
if (Buffer.isBuffer(parsedBody)) {
|
|
349
362
|
// Security: check body size
|
|
350
363
|
if (parsedBody.length > MAX_BODY_SIZE) {
|
|
351
|
-
return reply.code(413).type('text/html').send(registerPage(null, 'Request body exceeds maximum size.', null, inviteOnly));
|
|
364
|
+
return reply.code(413).type('text/html').send(registerPage(null, 'Request body exceeds maximum size.', null, inviteOnly, ctx));
|
|
352
365
|
}
|
|
353
366
|
const bodyStr = parsedBody.toString();
|
|
354
367
|
if (contentType.includes('application/json')) {
|
|
@@ -364,7 +377,7 @@ export async function handleRegisterPost(request, reply, issuer, inviteOnly = fa
|
|
|
364
377
|
} else if (typeof parsedBody === 'string') {
|
|
365
378
|
// Security: check body size
|
|
366
379
|
if (parsedBody.length > MAX_BODY_SIZE) {
|
|
367
|
-
return reply.code(413).type('text/html').send(registerPage(null, 'Request body exceeds maximum size.', null, inviteOnly));
|
|
380
|
+
return reply.code(413).type('text/html').send(registerPage(null, 'Request body exceeds maximum size.', null, inviteOnly, ctx));
|
|
368
381
|
}
|
|
369
382
|
const params = new URLSearchParams(parsedBody);
|
|
370
383
|
parsedBody = Object.fromEntries(params.entries());
|
|
@@ -376,32 +389,50 @@ export async function handleRegisterPost(request, reply, issuer, inviteOnly = fa
|
|
|
376
389
|
if (inviteOnly) {
|
|
377
390
|
const inviteResult = await validateInvite(invite);
|
|
378
391
|
if (!inviteResult.valid) {
|
|
379
|
-
return reply.code(403).type('text/html').send(registerPage(uid, inviteResult.error, null, inviteOnly));
|
|
392
|
+
return reply.code(403).type('text/html').send(registerPage(uid, inviteResult.error, null, inviteOnly, ctx));
|
|
380
393
|
}
|
|
381
394
|
}
|
|
382
395
|
|
|
383
396
|
// Validate input
|
|
384
397
|
if (!username || !password) {
|
|
385
|
-
return reply.type('text/html').send(registerPage(uid, 'Username and password are required', null, inviteOnly));
|
|
398
|
+
return reply.type('text/html').send(registerPage(uid, 'Username and password are required', null, inviteOnly, ctx));
|
|
386
399
|
}
|
|
387
400
|
|
|
388
|
-
// Validate username format
|
|
389
|
-
|
|
401
|
+
// Validate username format. Must start and end alphanumeric; the middle
|
|
402
|
+
// can contain dot, dash, underscore — covers `alice-smith`, `alice.smith`,
|
|
403
|
+
// `alice_work`, and so on. No leading/trailing separators (avoids the
|
|
404
|
+
// `.hidden` / trailing-dot footguns), no `..` (path traversal hygiene
|
|
405
|
+
// even though storage already guards against it).
|
|
406
|
+
//
|
|
407
|
+
// In subdomain mode the username becomes a single-level subdomain — DNS
|
|
408
|
+
// hostnames don't allow `.` or `_`, and `server.js` already refuses to
|
|
409
|
+
// route multi-level subdomains as pods. So we restrict to alphanumeric +
|
|
410
|
+
// hyphen there to keep the username and the pod actually addressable.
|
|
411
|
+
const subdomainMode = !!(request.subdomainsEnabled && request.baseDomain);
|
|
412
|
+
const usernameRegex = subdomainMode
|
|
413
|
+
? /^[a-z0-9]([a-z0-9-]{1,30}[a-z0-9])?$/
|
|
414
|
+
: /^[a-z0-9]([a-z0-9._-]{1,30}[a-z0-9])?$/;
|
|
390
415
|
if (!usernameRegex.test(username)) {
|
|
391
|
-
|
|
416
|
+
const msg = subdomainMode
|
|
417
|
+
? 'Username must be lowercase letters, numbers, or - (subdomain mode disallows . and _)'
|
|
418
|
+
: 'Username must be lowercase letters, numbers, or . _ - (start and end alphanumeric)';
|
|
419
|
+
return reply.type('text/html').send(registerPage(uid, msg, null, inviteOnly, ctx));
|
|
420
|
+
}
|
|
421
|
+
if (username.includes('..')) {
|
|
422
|
+
return reply.type('text/html').send(registerPage(uid, 'Username cannot contain ".."', null, inviteOnly, ctx));
|
|
392
423
|
}
|
|
393
424
|
|
|
394
425
|
if (username.length < 3) {
|
|
395
|
-
return reply.type('text/html').send(registerPage(uid, 'Username must be at least 3 characters', null, inviteOnly));
|
|
426
|
+
return reply.type('text/html').send(registerPage(uid, 'Username must be at least 3 characters', null, inviteOnly, ctx));
|
|
396
427
|
}
|
|
397
428
|
|
|
398
429
|
// Password strength validation
|
|
399
430
|
if (password.length < 8) {
|
|
400
|
-
return reply.type('text/html').send(registerPage(uid, 'Password must be at least 8 characters', null, inviteOnly));
|
|
431
|
+
return reply.type('text/html').send(registerPage(uid, 'Password must be at least 8 characters', null, inviteOnly, ctx));
|
|
401
432
|
}
|
|
402
433
|
|
|
403
434
|
if (password !== confirmPassword) {
|
|
404
|
-
return reply.type('text/html').send(registerPage(uid, 'Passwords do not match', null, inviteOnly));
|
|
435
|
+
return reply.type('text/html').send(registerPage(uid, 'Passwords do not match', null, inviteOnly, ctx));
|
|
405
436
|
}
|
|
406
437
|
|
|
407
438
|
try {
|
|
@@ -425,7 +456,7 @@ export async function handleRegisterPost(request, reply, issuer, inviteOnly = fa
|
|
|
425
456
|
const podPath = `${username}/`;
|
|
426
457
|
const podExists = await storage.exists(podPath);
|
|
427
458
|
if (podExists) {
|
|
428
|
-
return reply.type('text/html').send(registerPage(uid, 'Username is already taken', null, inviteOnly));
|
|
459
|
+
return reply.type('text/html').send(registerPage(uid, 'Username is already taken', null, inviteOnly, ctx));
|
|
429
460
|
}
|
|
430
461
|
|
|
431
462
|
// Create pod structure
|
|
@@ -445,11 +476,11 @@ export async function handleRegisterPost(request, reply, issuer, inviteOnly = fa
|
|
|
445
476
|
if (uid) {
|
|
446
477
|
return reply.redirect(`/idp/interaction/${uid}`);
|
|
447
478
|
} else {
|
|
448
|
-
return reply.type('text/html').send(registerPage(null, null, `Account created! You can now sign in as "${username}".`, inviteOnly));
|
|
479
|
+
return reply.type('text/html').send(registerPage(null, null, `Account created! You can now sign in as "${username}".`, inviteOnly, ctx));
|
|
449
480
|
}
|
|
450
481
|
} catch (err) {
|
|
451
482
|
request.log.error(err, 'Registration error');
|
|
452
|
-
return reply.type('text/html').send(registerPage(uid, err.message, null, inviteOnly));
|
|
483
|
+
return reply.type('text/html').send(registerPage(uid, err.message, null, inviteOnly, ctx));
|
|
453
484
|
}
|
|
454
485
|
}
|
|
455
486
|
|
package/src/idp/views.js
CHANGED
|
@@ -550,16 +550,110 @@ export function errorPage(title, message) {
|
|
|
550
550
|
`;
|
|
551
551
|
}
|
|
552
552
|
|
|
553
|
+
/**
|
|
554
|
+
* Friendly landing page for the IdP root.
|
|
555
|
+
*
|
|
556
|
+
* The OIDC authorization endpoint (/idp/auth) requires a client_id; opening
|
|
557
|
+
* /idp manually used to drop the user into a raw OIDC error. This page is
|
|
558
|
+
* the human-navigable entry point — Create Account is wired up; Sign In is
|
|
559
|
+
* intentionally a description for now (PR-B / #286 will introduce a
|
|
560
|
+
* standalone /idp/login form).
|
|
561
|
+
*/
|
|
562
|
+
export function landingPage(ctx = {}) {
|
|
563
|
+
const issuer = ctx.baseUri || '';
|
|
564
|
+
return `
|
|
565
|
+
<!DOCTYPE html>
|
|
566
|
+
<html lang="en">
|
|
567
|
+
<head>
|
|
568
|
+
<meta charset="UTF-8">
|
|
569
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
570
|
+
<title>Solid Pod Server</title>
|
|
571
|
+
<style>${styles}
|
|
572
|
+
/* landingPage local polish (#286) */
|
|
573
|
+
.container.landing { padding-top: 32px; }
|
|
574
|
+
.landing-header {
|
|
575
|
+
margin: -40px -40px 24px;
|
|
576
|
+
padding: 32px 40px 26px;
|
|
577
|
+
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
|
|
578
|
+
color: #fff;
|
|
579
|
+
border-radius: 12px 12px 0 0;
|
|
580
|
+
text-align: center;
|
|
581
|
+
}
|
|
582
|
+
.landing-header h1 { color: #fff; margin: 0 0 6px; font-size: 24px; }
|
|
583
|
+
.landing-header .subtitle { color: rgba(255,255,255,.85); margin: 0; font-size: 14px; }
|
|
584
|
+
.landing .signin-note {
|
|
585
|
+
margin-top: 18px;
|
|
586
|
+
padding: 14px 16px;
|
|
587
|
+
background: #f8fafc;
|
|
588
|
+
border: 1px solid #e2e8f0;
|
|
589
|
+
border-radius: 8px;
|
|
590
|
+
color: #475569;
|
|
591
|
+
font-size: 13px;
|
|
592
|
+
line-height: 1.55;
|
|
593
|
+
}
|
|
594
|
+
.landing .signin-note strong { color: #1e293b; }
|
|
595
|
+
.landing .issuer {
|
|
596
|
+
margin-top: 18px;
|
|
597
|
+
text-align: center;
|
|
598
|
+
color: #94a3b8;
|
|
599
|
+
font: 11px/1.4 ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
600
|
+
word-break: break-all;
|
|
601
|
+
}
|
|
602
|
+
</style>
|
|
603
|
+
</head>
|
|
604
|
+
<body>
|
|
605
|
+
<div class="container landing">
|
|
606
|
+
<div class="landing-header">
|
|
607
|
+
<h1>Solid Pod Server</h1>
|
|
608
|
+
<p class="subtitle">Create an account, then sign in from any Solid app.</p>
|
|
609
|
+
</div>
|
|
610
|
+
|
|
611
|
+
<a href="/idp/register" class="btn btn-primary" style="text-decoration: none;">Create Account</a>
|
|
612
|
+
|
|
613
|
+
<div class="signin-note">
|
|
614
|
+
<strong>Already have an account?</strong> Sign in from inside the Solid app you want to use — the app will redirect here when authentication is needed.
|
|
615
|
+
</div>
|
|
616
|
+
|
|
617
|
+
${issuer ? `<div class="issuer">Issuer: ${escapeHtml(issuer.replace(/\/$/, ''))}</div>` : ''}
|
|
618
|
+
</div>
|
|
619
|
+
</body>
|
|
620
|
+
</html>
|
|
621
|
+
`;
|
|
622
|
+
}
|
|
623
|
+
|
|
553
624
|
/**
|
|
554
625
|
* Registration page HTML
|
|
555
626
|
*/
|
|
556
|
-
export function registerPage(uid = null, error = null, success = null, inviteOnly = false) {
|
|
627
|
+
export function registerPage(uid = null, error = null, success = null, inviteOnly = false, ctx = {}) {
|
|
557
628
|
const inviteField = inviteOnly ? `
|
|
558
629
|
<label for="invite">Invite Code</label>
|
|
559
630
|
<input type="text" id="invite" name="invite" required
|
|
560
631
|
placeholder="Enter your invite code" style="text-transform: uppercase;">
|
|
561
632
|
` : '';
|
|
562
633
|
|
|
634
|
+
// Embed the values the live preview needs. Escape characters that are
|
|
635
|
+
// unsafe in inline <script> contexts so values like "</script>" or
|
|
636
|
+
// U+2028 / U+2029 line separators can't terminate the script tag or
|
|
637
|
+
// confuse the parser when template-substituted.
|
|
638
|
+
const previewConfig = JSON.stringify({
|
|
639
|
+
baseUri: ctx.baseUri || '',
|
|
640
|
+
subdomainsEnabled: !!ctx.subdomainsEnabled,
|
|
641
|
+
baseDomain: ctx.baseDomain || '',
|
|
642
|
+
})
|
|
643
|
+
.replace(/</g, '\\u003c')
|
|
644
|
+
.replace(/\u2028/g, '\\u2028')
|
|
645
|
+
.replace(/\u2029/g, '\\u2029');
|
|
646
|
+
|
|
647
|
+
// Server validates more strictly than the HTML pattern can express; mirror
|
|
648
|
+
// as much as possible client-side so the browser catches obvious mistakes
|
|
649
|
+
// before submit. Subdomain mode drops dot/underscore (DNS hostname rules).
|
|
650
|
+
const usernamePattern = (ctx.subdomainsEnabled && ctx.baseDomain)
|
|
651
|
+
? '[a-z0-9](?:[a-z0-9-]{1,30}[a-z0-9])?'
|
|
652
|
+
: '(?!.*\\.\\.)[a-z0-9](?:[a-z0-9._-]{1,30}[a-z0-9])?';
|
|
653
|
+
const usernameTitle = (ctx.subdomainsEnabled && ctx.baseDomain)
|
|
654
|
+
? 'Lowercase letters, numbers, or - (start and end alphanumeric, 3–32 chars). Subdomain mode disallows . and _.'
|
|
655
|
+
: 'Lowercase letters, numbers, or . _ - (start and end alphanumeric, 3–32 chars, no consecutive dots)';
|
|
656
|
+
|
|
563
657
|
return `
|
|
564
658
|
<!DOCTYPE html>
|
|
565
659
|
<html lang="en">
|
|
@@ -567,13 +661,38 @@ export function registerPage(uid = null, error = null, success = null, inviteOnl
|
|
|
567
661
|
<meta charset="UTF-8">
|
|
568
662
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
569
663
|
<title>Register - Solid IdP</title>
|
|
570
|
-
<style>${styles}
|
|
664
|
+
<style>${styles}
|
|
665
|
+
/* registerPage local polish (#284) */
|
|
666
|
+
.container.register { padding-top: 32px; }
|
|
667
|
+
.register-header {
|
|
668
|
+
margin: -40px -40px 24px;
|
|
669
|
+
padding: 28px 40px 22px;
|
|
670
|
+
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
|
|
671
|
+
color: #fff;
|
|
672
|
+
border-radius: 12px 12px 0 0;
|
|
673
|
+
}
|
|
674
|
+
.register-header h1 { color: #fff; margin: 0 0 4px; font-size: 22px; }
|
|
675
|
+
.register-header .subtitle { color: rgba(255,255,255,.85); margin: 0; font-size: 13px; }
|
|
676
|
+
.preview {
|
|
677
|
+
margin: 4px 0 18px;
|
|
678
|
+
padding: 12px 14px;
|
|
679
|
+
background: #f8fafc;
|
|
680
|
+
border: 1px solid #e2e8f0;
|
|
681
|
+
border-radius: 8px;
|
|
682
|
+
font: 12px/1.55 ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
683
|
+
color: #475569;
|
|
684
|
+
word-break: break-all;
|
|
685
|
+
}
|
|
686
|
+
.preview .label { color: #64748b; font-weight: 600; margin-right: 6px; }
|
|
687
|
+
.preview .placeholder { color: #94a3b8; font-style: italic; }
|
|
688
|
+
</style>
|
|
571
689
|
</head>
|
|
572
690
|
<body>
|
|
573
|
-
<div class="container">
|
|
574
|
-
<div class="
|
|
575
|
-
|
|
576
|
-
|
|
691
|
+
<div class="container register">
|
|
692
|
+
<div class="register-header">
|
|
693
|
+
<h1>Create Account</h1>
|
|
694
|
+
<p class="subtitle">Register for a new Solid Pod${inviteOnly ? ' (invite required)' : ''}</p>
|
|
695
|
+
</div>
|
|
577
696
|
|
|
578
697
|
${error ? `<div class="error">${escapeHtml(error)}</div>` : ''}
|
|
579
698
|
${success ? `<div class="error" style="background: #efe; border-color: #cfc; color: #060;">${escapeHtml(success)}</div>` : ''}
|
|
@@ -583,8 +702,14 @@ export function registerPage(uid = null, error = null, success = null, inviteOnl
|
|
|
583
702
|
|
|
584
703
|
<label for="username">Username</label>
|
|
585
704
|
<input type="text" id="username" name="username" required ${!inviteOnly ? 'autofocus' : ''}
|
|
586
|
-
placeholder="Choose a username"
|
|
587
|
-
|
|
705
|
+
placeholder="Choose a username" minlength="3" maxlength="32"
|
|
706
|
+
pattern="${usernamePattern}"
|
|
707
|
+
title="${usernameTitle}">
|
|
708
|
+
|
|
709
|
+
<div class="preview" id="preview" aria-live="polite">
|
|
710
|
+
<div><span class="label">WebID</span><span id="preview-webid" class="placeholder">choose a username to preview</span></div>
|
|
711
|
+
<div style="margin-top: 4px;"><span class="label">Storage</span><span id="preview-storage" class="placeholder">—</span></div>
|
|
712
|
+
</div>
|
|
588
713
|
|
|
589
714
|
<label for="password">Password</label>
|
|
590
715
|
<input type="password" id="password" name="password" required
|
|
@@ -598,9 +723,51 @@ export function registerPage(uid = null, error = null, success = null, inviteOnl
|
|
|
598
723
|
</form>
|
|
599
724
|
|
|
600
725
|
<p style="text-align: center; margin-top: 24px; color: #666; font-size: 14px;">
|
|
601
|
-
|
|
726
|
+
${uid
|
|
727
|
+
? `Already have an account? <a href="/idp/interaction/${uid}" style="color: #0066cc;">Sign In</a>`
|
|
728
|
+
: `<a href="/idp" style="color: #0066cc;">Back to home</a>`}
|
|
602
729
|
</p>
|
|
603
730
|
</div>
|
|
731
|
+
|
|
732
|
+
<script>
|
|
733
|
+
(function () {
|
|
734
|
+
var cfg = ${previewConfig};
|
|
735
|
+
var input = document.getElementById('username');
|
|
736
|
+
var webEl = document.getElementById('preview-webid');
|
|
737
|
+
var storEl = document.getElementById('preview-storage');
|
|
738
|
+
if (!input || !webEl || !storEl) return;
|
|
739
|
+
|
|
740
|
+
function render() {
|
|
741
|
+
// Server rejects uppercase outright, so normalise the field as the
|
|
742
|
+
// user types — keeps the preview honest and avoids a confusing
|
|
743
|
+
// post-submit error.
|
|
744
|
+
var normalised = (input.value || '').toLowerCase();
|
|
745
|
+
if (input.value !== normalised) input.value = normalised;
|
|
746
|
+
var u = normalised.trim();
|
|
747
|
+
if (!u) {
|
|
748
|
+
webEl.textContent = 'choose a username to preview';
|
|
749
|
+
webEl.className = 'placeholder';
|
|
750
|
+
storEl.textContent = '—';
|
|
751
|
+
storEl.className = 'placeholder';
|
|
752
|
+
return;
|
|
753
|
+
}
|
|
754
|
+
var pod, webid;
|
|
755
|
+
if (cfg.subdomainsEnabled && cfg.baseDomain) {
|
|
756
|
+
var origin = cfg.baseUri.split('://')[0] + '://';
|
|
757
|
+
pod = origin + u + '.' + cfg.baseDomain + '/';
|
|
758
|
+
} else {
|
|
759
|
+
pod = (cfg.baseUri || (location.protocol + '//' + location.host)) + '/' + u + '/';
|
|
760
|
+
}
|
|
761
|
+
webid = pod + 'profile/card.jsonld#me';
|
|
762
|
+
webEl.textContent = webid;
|
|
763
|
+
webEl.className = '';
|
|
764
|
+
storEl.textContent = pod;
|
|
765
|
+
storEl.className = '';
|
|
766
|
+
}
|
|
767
|
+
input.addEventListener('input', render);
|
|
768
|
+
render();
|
|
769
|
+
})();
|
|
770
|
+
</script>
|
|
604
771
|
</body>
|
|
605
772
|
</html>
|
|
606
773
|
`;
|
package/src/server.js
CHANGED
|
@@ -436,7 +436,9 @@ export function createServer(options = {}) {
|
|
|
436
436
|
if (request.url === '/.pods' ||
|
|
437
437
|
request.url === '/.notifications' ||
|
|
438
438
|
request.method === 'OPTIONS' ||
|
|
439
|
+
request.url === '/idp' ||
|
|
439
440
|
request.url.startsWith('/idp/') ||
|
|
441
|
+
request.url.startsWith('/idp?') ||
|
|
440
442
|
request.url.startsWith('/.well-known/') ||
|
|
441
443
|
(nostrEnabled && request.url.startsWith(nostrPath)) ||
|
|
442
444
|
(gitEnabled && isGitRequest(request.url)) ||
|
package/test/idp.test.js
CHANGED
|
@@ -181,6 +181,177 @@ describe('Identity Provider', () => {
|
|
|
181
181
|
assert.ok(res.status >= 200 && res.status < 600, `got valid HTTP status ${res.status}`);
|
|
182
182
|
});
|
|
183
183
|
});
|
|
184
|
+
|
|
185
|
+
// Regression coverage for #286 — friendly /idp landing + /idp/auth guard.
|
|
186
|
+
describe('Landing page', () => {
|
|
187
|
+
it('GET /idp returns the landing HTML', async () => {
|
|
188
|
+
const res = await fetch(`${baseUrl}/idp`);
|
|
189
|
+
assert.strictEqual(res.status, 200);
|
|
190
|
+
assert.match(res.headers.get('content-type') || '', /text\/html/);
|
|
191
|
+
const body = await res.text();
|
|
192
|
+
assert.match(body, /Solid Pod Server/);
|
|
193
|
+
assert.match(body, /Create Account/);
|
|
194
|
+
assert.match(body, /href="\/idp\/register"/);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it('GET /idp/auth without client_id redirects to /idp', async () => {
|
|
198
|
+
const res = await fetch(`${baseUrl}/idp/auth`, { redirect: 'manual' });
|
|
199
|
+
assert.strictEqual(res.status, 302);
|
|
200
|
+
assert.strictEqual(res.headers.get('location'), '/idp');
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it('HEAD /idp/auth without client_id also redirects to /idp', async () => {
|
|
204
|
+
// Fastify auto-creates HEAD handlers for GET routes; the guard must
|
|
205
|
+
// catch HEAD too so probing tools land on the friendly page rather
|
|
206
|
+
// than the raw OIDC error.
|
|
207
|
+
const res = await fetch(`${baseUrl}/idp/auth`, { method: 'HEAD', redirect: 'manual' });
|
|
208
|
+
assert.strictEqual(res.status, 302);
|
|
209
|
+
assert.strictEqual(res.headers.get('location'), '/idp');
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it('GET /idp/auth WITH client_id still reaches oidc-provider', async () => {
|
|
213
|
+
// Sanity: the guard must not block real OIDC requests. The provider
|
|
214
|
+
// may legitimately redirect (e.g. to /idp/interaction/:uid) for
|
|
215
|
+
// valid client/parameter combinations, so we test the precise
|
|
216
|
+
// contract — the guard's specific 302→/idp response — rather than
|
|
217
|
+
// "no redirect at all".
|
|
218
|
+
const res = await fetch(
|
|
219
|
+
`${baseUrl}/idp/auth?client_id=test&redirect_uri=http://localhost&response_type=code&scope=openid`,
|
|
220
|
+
{ redirect: 'manual' }
|
|
221
|
+
);
|
|
222
|
+
const location = res.headers.get('location');
|
|
223
|
+
assert.ok(
|
|
224
|
+
!(res.status === 302 && location === '/idp'),
|
|
225
|
+
`request should bypass the /idp guard, got ${res.status} → ${location}`
|
|
226
|
+
);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it('GET /idp/auth with empty client_id is a malformed OIDC request, not bare', async () => {
|
|
230
|
+
// Tightened guard (=== undefined) lets ?client_id= pass through to
|
|
231
|
+
// oidc-provider rather than redirecting to /idp.
|
|
232
|
+
const res = await fetch(`${baseUrl}/idp/auth?client_id=`, { redirect: 'manual' });
|
|
233
|
+
assert.notStrictEqual(res.headers.get('location'), '/idp');
|
|
234
|
+
});
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
// Regression coverage for #284 — relaxed username regex + `..` rejection.
|
|
238
|
+
// Each register call below also exercises the .jsonld pod-creation flow
|
|
239
|
+
// from #283, since handleRegisterPost calls createPodStructure on success.
|
|
240
|
+
describe('Register username validation (path mode)', () => {
|
|
241
|
+
async function tryRegister(username) {
|
|
242
|
+
const res = await fetch(`${baseUrl}/idp/register`, {
|
|
243
|
+
method: 'POST',
|
|
244
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
245
|
+
body: new URLSearchParams({ username, password: 'secret-password', confirmPassword: 'secret-password' }),
|
|
246
|
+
});
|
|
247
|
+
const body = await res.text();
|
|
248
|
+
return { status: res.status, body };
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
it('accepts plain alphanumeric (alice)', async () => {
|
|
252
|
+
const r = await tryRegister('alice');
|
|
253
|
+
assert.match(r.body, /Account created/);
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it('accepts dash (alice-smith)', async () => {
|
|
257
|
+
const r = await tryRegister('alice-smith');
|
|
258
|
+
assert.match(r.body, /Account created/);
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it('accepts dot (alice.smith)', async () => {
|
|
262
|
+
const r = await tryRegister('alice.smith');
|
|
263
|
+
assert.match(r.body, /Account created/);
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it('accepts underscore (alice_work)', async () => {
|
|
267
|
+
const r = await tryRegister('alice_work');
|
|
268
|
+
assert.match(r.body, /Account created/);
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
it('rejects leading separator (.alice)', async () => {
|
|
272
|
+
const r = await tryRegister('.alice');
|
|
273
|
+
assert.match(r.body, /lowercase letters, numbers/);
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
it('rejects trailing separator (alice-)', async () => {
|
|
277
|
+
const r = await tryRegister('alice-');
|
|
278
|
+
assert.match(r.body, /lowercase letters, numbers/);
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
it('rejects consecutive dots (alice..bob)', async () => {
|
|
282
|
+
const r = await tryRegister('alice..bob');
|
|
283
|
+
// Quotes are HTML-escaped (") in the rendered error banner.
|
|
284
|
+
assert.match(r.body, /cannot contain (?:"|")\.\.(?:"|")/);
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it('rejects uppercase (Alice)', async () => {
|
|
288
|
+
const r = await tryRegister('Alice');
|
|
289
|
+
assert.match(r.body, /lowercase letters, numbers/);
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
it('rejects too short (ab)', async () => {
|
|
293
|
+
const r = await tryRegister('ab');
|
|
294
|
+
// Two-char names fail the regex (min 3 enforced by the pattern itself).
|
|
295
|
+
assert.match(r.body, /lowercase letters, numbers|at least 3/);
|
|
296
|
+
});
|
|
297
|
+
});
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
// Subdomain mode: usernames become hostname components, so `.` and `_` are
|
|
301
|
+
// not allowed (server.js refuses to route multi-level subdomains).
|
|
302
|
+
describe('Identity Provider - Subdomain mode register validation', () => {
|
|
303
|
+
let server;
|
|
304
|
+
let baseUrl;
|
|
305
|
+
const SUBDOMAIN_DATA_DIR = './test-data-idp-subdomain';
|
|
306
|
+
|
|
307
|
+
before(async () => {
|
|
308
|
+
await fs.remove(SUBDOMAIN_DATA_DIR);
|
|
309
|
+
await fs.ensureDir(SUBDOMAIN_DATA_DIR);
|
|
310
|
+
|
|
311
|
+
const port = await getAvailablePort();
|
|
312
|
+
baseUrl = `http://${TEST_HOST}:${port}`;
|
|
313
|
+
|
|
314
|
+
server = createServer({
|
|
315
|
+
logger: false,
|
|
316
|
+
root: SUBDOMAIN_DATA_DIR,
|
|
317
|
+
idp: true,
|
|
318
|
+
idpIssuer: baseUrl,
|
|
319
|
+
subdomains: true,
|
|
320
|
+
baseDomain: TEST_HOST,
|
|
321
|
+
forceCloseConnections: true,
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
await server.listen({ port, host: TEST_HOST });
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
after(async () => {
|
|
328
|
+
await server.close();
|
|
329
|
+
await fs.remove(SUBDOMAIN_DATA_DIR);
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
async function tryRegister(username) {
|
|
333
|
+
const res = await fetch(`${baseUrl}/idp/register`, {
|
|
334
|
+
method: 'POST',
|
|
335
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
336
|
+
body: new URLSearchParams({ username, password: 'secret-password', confirmPassword: 'secret-password' }),
|
|
337
|
+
});
|
|
338
|
+
return { status: res.status, body: await res.text() };
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
it('accepts dash (alice-smith)', async () => {
|
|
342
|
+
const r = await tryRegister('alice-smith');
|
|
343
|
+
assert.match(r.body, /Account created/);
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
it('rejects dot (alice.smith) — would not be a single-level subdomain', async () => {
|
|
347
|
+
const r = await tryRegister('alice.smith');
|
|
348
|
+
assert.match(r.body, /subdomain mode disallows/);
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
it('rejects underscore (alice_work) — invalid in DNS hostnames', async () => {
|
|
352
|
+
const r = await tryRegister('alice_work');
|
|
353
|
+
assert.match(r.body, /subdomain mode disallows/);
|
|
354
|
+
});
|
|
184
355
|
});
|
|
185
356
|
|
|
186
357
|
describe('Identity Provider - Accounts', () => {
|