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
35 changes: 34 additions & 1 deletion src/idp/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
} from './credentials.js';
import * as passkey from './passkey.js';
import { addTrustedIssuer } from '../auth/solid-oidc.js';
import { landingPage } from './views.js';

/**
* IdP Fastify Plugin
Expand Down Expand Up @@ -140,7 +141,7 @@ export async function idpPlugin(fastify, options) {

// Catch-all route for oidc-provider paths
// Must be registered BEFORE specific routes to be matched as fallback
const oidcPaths = ['/idp/auth', '/idp/token', '/idp/reg', '/idp/me', '/idp/session', '/idp/session/*'];
const oidcPaths = ['/idp/token', '/idp/reg', '/idp/me', '/idp/session', '/idp/session/*'];

for (const path of oidcPaths) {
fastify.route({
Expand All @@ -150,9 +151,41 @@ export async function idpPlugin(fastify, options) {
});
}

// /idp/auth: guard against the human-fat-fingered case where someone opens
// the URL directly with no `client_id`. oidc-provider would otherwise
// return a raw `invalid_request` error page; we 302 to the friendly
// landing instead. Real OIDC requests (with client_id) pass through.
fastify.route({
// HEAD is listed explicitly so the route's contract doesn't depend on
// Fastify's auto-HEAD-from-GET (which `exposeHeadRoutes` can disable);
// the handler below treats HEAD the same as GET for the bare-client_id
// redirect.
method: ['GET', 'HEAD', 'POST', 'DELETE', 'OPTIONS'],
url: '/idp/auth',
handler: async (request, reply) => {
// Only catch the truly-bare case (no `client_id` param at all). An
// explicit empty string is a malformed OIDC request — let
// oidc-provider surface the spec error instead of redirecting.
if (
(request.method === 'GET' || request.method === 'HEAD') &&
request.query?.client_id === undefined
Comment on lines +158 to +171

Copilot AI Apr 22, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The /idp/auth route handler has logic and comments referencing HEAD requests, but HEAD is not explicitly included in the route's method list (it currently relies on Fastify's auto-HEAD-from-GET behavior). To make the contract explicit and avoid future regressions if Fastify config changes (e.g., exposeHeadRoutes), add 'HEAD' to the method array (or remove the HEAD-specific logic/comment if you intend to rely on auto-HEAD).

Copilot uses AI. Check for mistakes.
) {
return reply.redirect('/idp');
}
return forwardToProvider(request, reply);
},
});

// Also handle /idp/auth/:uid for continued authorization after login
fastify.get('/idp/auth/:uid', forwardToProvider);

// Friendly landing — Create Account button + sign-in note.
// Pairs with the /idp/auth guard above so a human visitor lands here
// rather than on a raw OIDC error.
fastify.get('/idp', async (request, reply) => {
return reply.type('text/html').send(landingPage({ baseUri: issuer }));
});

// Token sub-paths
fastify.route({
method: ['GET', 'POST'],
Expand Down
75 changes: 74 additions & 1 deletion src/idp/views.js
Original file line number Diff line number Diff line change
Expand Up @@ -550,6 +550,77 @@ export function errorPage(title, message) {
`;
}

/**
* Friendly landing page for the IdP root.
*
* The OIDC authorization endpoint (/idp/auth) requires a client_id; opening
* /idp manually used to drop the user into a raw OIDC error. This page is
* the human-navigable entry point — Create Account is wired up; Sign In is
* intentionally a description for now (PR-B / #286 will introduce a
* standalone /idp/login form).
*/
export function landingPage(ctx = {}) {
const issuer = ctx.baseUri || '';
return `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Solid Pod Server</title>
<style>${styles}
/* landingPage local polish (#286) */
.container.landing { padding-top: 32px; }
.landing-header {
margin: -40px -40px 24px;
padding: 32px 40px 26px;
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
color: #fff;
border-radius: 12px 12px 0 0;
text-align: center;
}
.landing-header h1 { color: #fff; margin: 0 0 6px; font-size: 24px; }
.landing-header .subtitle { color: rgba(255,255,255,.85); margin: 0; font-size: 14px; }
.landing .signin-note {
margin-top: 18px;
padding: 14px 16px;
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 8px;
color: #475569;
font-size: 13px;
line-height: 1.55;
}
.landing .signin-note strong { color: #1e293b; }
.landing .issuer {
margin-top: 18px;
text-align: center;
color: #94a3b8;
font: 11px/1.4 ui-monospace, SFMono-Regular, Menlo, monospace;
word-break: break-all;
}
</style>
</head>
<body>
<div class="container landing">
<div class="landing-header">
<h1>Solid Pod Server</h1>
<p class="subtitle">Create an account, then sign in from any Solid app.</p>
</div>

<a href="/idp/register" class="btn btn-primary" style="text-decoration: none;">Create Account</a>

<div class="signin-note">
<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.
</div>

${issuer ? `<div class="issuer">Issuer: ${escapeHtml(issuer.replace(/\/$/, ''))}</div>` : ''}
</div>
</body>
</html>
`;
}

/**
* Registration page HTML
*/
Expand Down Expand Up @@ -652,7 +723,9 @@ export function registerPage(uid = null, error = null, success = null, inviteOnl
</form>

<p style="text-align: center; margin-top: 24px; color: #666; font-size: 14px;">
Already have an account? <a href="${uid ? `/idp/interaction/${uid}` : '/idp/auth'}" style="color: #0066cc;">Sign In</a>
${uid
? `Already have an account? <a href="/idp/interaction/${uid}" style="color: #0066cc;">Sign In</a>`
: `<a href="/idp" style="color: #0066cc;">Back to home</a>`}
</p>
</div>

Expand Down
2 changes: 2 additions & 0 deletions src/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -436,7 +436,9 @@ export function createServer(options = {}) {
if (request.url === '/.pods' ||
request.url === '/.notifications' ||
request.method === 'OPTIONS' ||
request.url === '/idp' ||
request.url.startsWith('/idp/') ||
request.url.startsWith('/idp?') ||
request.url.startsWith('/.well-known/') ||
(nostrEnabled && request.url.startsWith(nostrPath)) ||
(gitEnabled && isGitRequest(request.url)) ||
Expand Down
52 changes: 52 additions & 0 deletions test/idp.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,58 @@ describe('Identity Provider', () => {
});
});

// Regression coverage for #286 — friendly /idp landing + /idp/auth guard.
describe('Landing page', () => {
it('GET /idp returns the landing HTML', async () => {
const res = await fetch(`${baseUrl}/idp`);
assert.strictEqual(res.status, 200);
assert.match(res.headers.get('content-type') || '', /text\/html/);
const body = await res.text();
assert.match(body, /Solid Pod Server/);
assert.match(body, /Create Account/);
assert.match(body, /href="\/idp\/register"/);
});

it('GET /idp/auth without client_id redirects to /idp', async () => {
const res = await fetch(`${baseUrl}/idp/auth`, { redirect: 'manual' });
assert.strictEqual(res.status, 302);
assert.strictEqual(res.headers.get('location'), '/idp');
});

it('HEAD /idp/auth without client_id also redirects to /idp', async () => {
// Fastify auto-creates HEAD handlers for GET routes; the guard must
// catch HEAD too so probing tools land on the friendly page rather
// than the raw OIDC error.
const res = await fetch(`${baseUrl}/idp/auth`, { method: 'HEAD', redirect: 'manual' });
assert.strictEqual(res.status, 302);
assert.strictEqual(res.headers.get('location'), '/idp');
});

it('GET /idp/auth WITH client_id still reaches oidc-provider', async () => {
// Sanity: the guard must not block real OIDC requests. The provider
// may legitimately redirect (e.g. to /idp/interaction/:uid) for
// valid client/parameter combinations, so we test the precise
// contract — the guard's specific 302→/idp response — rather than
// "no redirect at all".
const res = await fetch(
`${baseUrl}/idp/auth?client_id=test&redirect_uri=http://localhost&response_type=code&scope=openid`,
{ redirect: 'manual' }
);
const location = res.headers.get('location');
assert.ok(
!(res.status === 302 && location === '/idp'),
`request should bypass the /idp guard, got ${res.status} → ${location}`
);
});

it('GET /idp/auth with empty client_id is a malformed OIDC request, not bare', async () => {
// Tightened guard (=== undefined) lets ?client_id= pass through to
// oidc-provider rather than redirecting to /idp.
const res = await fetch(`${baseUrl}/idp/auth?client_id=`, { redirect: 'manual' });
assert.notStrictEqual(res.headers.get('location'), '/idp');
});
});

// Regression coverage for #284 — relaxed username regex + `..` rejection.
// Each register call below also exercises the .jsonld pod-creation flow
// from #283, since handleRegisterPost calls createPodStructure on success.
Expand Down