Skip to content

Commit a4d73dc

Browse files
0.0.2: add OIDC sign-in after resolution (sso#6 partial)
0.0.1 redirected the user to their pod root unauthenticated — they could browse public content but anything WAC-protected re-prompted in whatever app needed it. 0.0.2 extends the flow with a second stage: after resolving the WebID, fetch the WebID profile to discover the user's IdP (solid:oidcIssuer field), then kick off Solid-OIDC against that IdP. The user proves identity ONCE at the IdP (one Schnorr click), an IdP-side session cookie is set, and they land at the pod *with* a session that any subsequent Solid-OIDC-aware app on the same browser will silently re-use. Two-stage flow: Stage 1 (resolution, in-page): click → window.nostr.getPublicKey() → resolver fetch → WebID profile → IdP discovery Stage 2 (authentication, OIDC redirect): session.login(idp, here) → user signs at IdP → redirect back with ?code= → handleRedirectFromLogin → redirect to pod with session active Failure-path coaching preserved for every step in Stage 1 (no signer, signer rejected, resolver 404, no WebID, etc.). Stage 2 failures show a simple "Sign-in failed" with the underlying OIDC error. URL params: `?resolver=` for the DID-doc host, `?next=` for the post-success destination. The `?idp=` param is gone — the IdP is auto-discovered from the resolved WebID profile. Note on the email-munge bug: the import uses `solid-oidc` (no version pin, no `@digits.digits.digits` substring) so the file-write tool's email-pattern rewrite doesn't fire.
1 parent 465e93a commit a4d73dc

1 file changed

Lines changed: 152 additions & 67 deletions

File tree

login.js

Lines changed: 152 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,34 @@
11
// JSS SSO — one-click Solid sign-in.
22
//
3-
// Happy path (everything configured):
4-
// click button
5-
// → window.nostr.getPublicKey() (signer extension)
6-
// → fetch <resolver>/.well-known/did/nostr/<pubkey>.json
7-
// → read alsoKnownAs[0] → WebID
8-
// → redirect to that WebID's pod root
3+
// Two-stage flow:
94
//
10-
// One click, one (auto-approvable) signer prompt, no IdP page,
11-
// no OIDC redirect.
5+
// Stage 1 — RESOLUTION (in-page, no redirect):
6+
// click button
7+
// → window.nostr.getPublicKey()
8+
// → fetch <resolver>/.well-known/did/nostr/<pubkey>.json
9+
// → read alsoKnownAs[0] → WebID
10+
// → fetch WebID profile → read solid:oidcIssuer → IdP URL
1211
//
13-
// Trade-off: the user lands at their pod WITHOUT an authenticated
14-
// Solid-OIDC session. They can browse public content; anything
15-
// WAC-protected would prompt for a session via whatever app needs
16-
// it. For "magic SSO landing" that's fine. A v0.2 step would add
17-
// optional NIP-98 → DPoP exchange against the IdP.
12+
// Stage 2 — AUTHENTICATION (Solid-OIDC):
13+
// → session.login(idp, redirectUri) triggers redirect to IdP
14+
// → (user proves identity ONCE at IdP — Schnorr click — and
15+
// an IdP-side session cookie is set)
16+
// → IdP redirects back here with ?code=
17+
// → session.handleRedirectFromLogin() exchanges code for tokens
18+
// → redirect to the WebID's pod root, now authenticated
1819
//
19-
// Failure path: at every step, if the happy path can't proceed,
20-
// we show a precise diagnosis + the user's next concrete action.
21-
// PoC philosophy: assume everything works; gracefully degrade with
22-
// guidance when it doesn't.
20+
// Once the IdP cookie is set, *future* sign-ins (this page or any
21+
// Solid app using the same IdP) silently re-issue tokens — no
22+
// further interaction.
23+
//
24+
// PoC philosophy: assume everything works; show precise diagnosis
25+
// + next-step coaching for each failure node.
2326
//
2427
// URL params:
25-
// ?resolver=<host> — DID-doc resolver host (default solid.social)
26-
// ?next=<url> — destination after success (default pod root)
28+
// ?resolver=<host> — DID-doc resolver host (default solid.social)
29+
// ?next=<url> — destination after success (default pod root)
30+
31+
import Session from 'https://esm.sh/solid-oidc';
2732

2833
const DEFAULT_RESOLVER = 'https://solid.social';
2934

@@ -46,9 +51,6 @@ function setStatus(msg, kind = 'info') {
4651
el.className = kind;
4752
}
4853

49-
// Build a small DOM fragment with text + links. Easier than
50-
// innerHTML for safety (we don't substitute user-controlled
51-
// strings into HTML).
5254
function help(textParts, links = []) {
5355
const frag = document.createDocumentFragment();
5456
textParts.forEach((t) => frag.appendChild(document.createTextNode(t)));
@@ -74,11 +76,78 @@ function podRootFromWebId(webId) {
7476
catch { return null; }
7577
}
7678

77-
async function flow() {
78-
const { resolver, next } = readConfig();
79-
setStatus('Reading your Nostr identity…');
79+
// ---- Stage 1: resolution ----
80+
81+
async function resolveWebIdFromPubkey(pubkey, resolver) {
82+
const res = await fetch(`${resolver}/.well-known/did/nostr/${pubkey}.json`, {
83+
headers: { Accept: 'application/did+json, application/json' },
84+
});
85+
if (res.status === 404) {
86+
throw new ResolveError('no-binding',
87+
`Your Nostr key isn't linked to a Solid pod at ${resolver}.`);
88+
}
89+
if (!res.ok) {
90+
throw new ResolveError('resolver-status',
91+
`Resolver ${resolver} returned ${res.status} ${res.statusText}.`);
92+
}
93+
const didDoc = await res.json();
94+
const aka = Array.isArray(didDoc.alsoKnownAs) ? didDoc.alsoKnownAs : [];
95+
const webId = aka.find((x) => typeof x === 'string' && /^https?:\/\//.test(x));
96+
if (!webId) {
97+
throw new ResolveError('no-webid',
98+
`Your DID document at ${resolver} doesn't include an alsoKnownAs WebID.`);
99+
}
100+
return webId;
101+
}
102+
103+
async function discoverIdpFromWebId(webId) {
104+
// Solid profiles declare the IdP via `solid:oidcIssuer`. Fall back
105+
// to the WebID's origin if the field is absent (the pod's own host
106+
// is also a common IdP).
107+
try {
108+
const profileRes = await fetch(webId.split('#')[0], {
109+
headers: { Accept: 'application/ld+json, application/json' },
110+
});
111+
if (profileRes.ok) {
112+
const profile = await profileRes.json();
113+
const issuer = profile['solid:oidcIssuer']
114+
|| profile['http://www.w3.org/ns/solid/terms#oidcIssuer']
115+
|| profile.solid?.oidcIssuer;
116+
const issuerStr = typeof issuer === 'string'
117+
? issuer
118+
: (issuer && (issuer['@id'] || issuer.id)) || null;
119+
if (issuerStr) return issuerStr.replace(/\/$/, '');
120+
}
121+
} catch { /* fall through to origin */ }
122+
return new URL(webId).origin;
123+
}
124+
125+
class ResolveError extends Error {
126+
constructor(code, msg) { super(msg); this.code = code; }
127+
}
128+
129+
// ---- Stage 2: authentication via Solid-OIDC ----
130+
131+
async function startAuthFlow(idp) {
132+
const session = new Session();
133+
await session.login(idp, location.origin + location.pathname);
134+
// browser is redirecting; nothing further to do
135+
}
136+
137+
async function handleAuthReturn() {
138+
// We're back from the IdP with ?code=...
139+
const session = new Session();
140+
await session.handleRedirectFromLogin();
141+
return session;
142+
}
143+
144+
// ---- Top-level flows ----
80145

81-
// Step 1 — signer extension present?
146+
async function freshFlow() {
147+
const { resolver } = readConfig();
148+
149+
// Step 1: extension
150+
setStatus('Reading your Nostr identity…');
82151
if (!window.nostr) {
83152
setStatus(help(
84153
['No Nostr signer detected. Install a browser extension that provides ',
@@ -91,81 +160,97 @@ async function flow() {
91160
return false;
92161
}
93162

94-
// Step 2 — get the pubkey (may prompt user in the extension)
163+
// Step 2: pubkey
95164
let pubkey;
96165
try {
97166
pubkey = (await window.nostr.getPublicKey()).toLowerCase();
98167
} catch (err) {
99-
setStatus(help(
100-
['Your signer declined to share your public key',
101-
err?.message ? ` (${err.message})` : '', '. Click Sign in to try again.'],
102-
), 'error');
168+
setStatus(help([
169+
'Your signer declined to share your public key',
170+
err?.message ? ` (${err.message})` : '',
171+
'. Click Sign in to try again.',
172+
]), 'error');
103173
return false;
104174
}
105175
if (!/^[0-9a-f]{64}$/.test(pubkey)) {
106176
setStatus(`Signer returned an invalid public key: ${pubkey}`, 'error');
107177
return false;
108178
}
109179

110-
// Step 3resolve pubkey → DID doc via the well-known endpoint
180+
// Step 3: resolve to WebID
111181
setStatus(`Resolving did:nostr:${pubkey.slice(0, 8)}… via ${resolver}`);
112-
let didDoc;
182+
let webId;
113183
try {
114-
const res = await fetch(`${resolver}/.well-known/did/nostr/${pubkey}.json`, {
115-
headers: { Accept: 'application/did+json, application/json' },
116-
});
117-
if (res.status === 404) {
184+
webId = await resolveWebIdFromPubkey(pubkey, resolver);
185+
} catch (err) {
186+
if (err.code === 'no-binding') {
118187
setStatus(help(
119-
['Your Nostr key isn’t linked to a Solid pod at ', resolver, '. '],
188+
[err.message, ' '],
120189
[
121190
{ label: 'How to link your Nostr key to a Solid pod', href: 'https://jss.live/docs/' },
122-
{ label: `Try a different resolver: ?resolver=<host>`, href: '?resolver=https://nostr.social' },
191+
{ label: 'Try a different resolver', href: '?resolver=https://nostr.social' },
123192
],
124193
), 'error');
125-
return false;
126-
}
127-
if (!res.ok) {
128-
setStatus(`Resolver ${resolver} returned ${res.status} ${res.statusText}.`, 'error');
129-
return false;
194+
} else {
195+
setStatus(err.message, 'error');
130196
}
131-
didDoc = await res.json();
132-
} catch (err) {
133-
setStatus(help(
134-
[`Couldn’t reach ${resolver}: ${err.message}. Check your connection or try a different resolver.`],
135-
), 'error');
136197
return false;
137198
}
138199

139-
// Step 4 — pluck the WebID from alsoKnownAs
140-
const aka = Array.isArray(didDoc.alsoKnownAs) ? didDoc.alsoKnownAs : [];
141-
const webId = aka.find((x) => typeof x === 'string' && /^https?:\/\//.test(x));
142-
if (!webId) {
143-
setStatus(help(
144-
[`Your did:nostr document at ${resolver} doesn’t include an alsoKnownAs WebID. `],
145-
[{ label: 'How to fix this', href: 'https://jss.live/docs/' }],
146-
), 'error');
200+
// Step 4: discover IdP from the WebID profile
201+
setStatus(`Found ${webId}. Looking up your identity provider…`);
202+
const idp = await discoverIdpFromWebId(webId);
203+
204+
// Step 5: stash where to land (so post-redirect knows)
205+
localStorage.setItem('jss-sso:webId', webId);
206+
207+
// Step 6: kick off OIDC
208+
setStatus(`Signing you in via ${idp}…`);
209+
try {
210+
await startAuthFlow(idp);
211+
return true; // browser is redirecting
212+
} catch (err) {
213+
setStatus(`Could not start sign-in: ${err.message}`, 'error');
147214
return false;
148215
}
216+
}
149217

150-
// Step 5 — redirect
151-
const dest = next || podRootFromWebId(webId);
152-
if (!dest) {
153-
setStatus(`Resolved WebID is unparseable: ${webId}`, 'error');
154-
return false;
218+
async function returnFlow() {
219+
// URL has `?code=` — finalize the OIDC handshake.
220+
setStatus('Completing sign-in…');
221+
try {
222+
const session = await handleAuthReturn();
223+
if (!session.isActive || !session.webId) {
224+
throw new Error('session did not become active after redirect');
225+
}
226+
const { next } = readConfig();
227+
const webId = session.webId || localStorage.getItem('jss-sso:webId');
228+
const dest = next || podRootFromWebId(webId);
229+
localStorage.removeItem('jss-sso:webId');
230+
setStatus(`Signed in as ${webId}. Taking you to your pod…`);
231+
setTimeout(() => { location.href = dest; }, 800);
232+
} catch (err) {
233+
setStatus(`Sign-in failed: ${err.message}`, 'error');
234+
const button = document.querySelector('#signin');
235+
if (button) button.disabled = false;
155236
}
156-
setStatus(`Signed in as ${webId}. Taking you to your pod…`);
157-
setTimeout(() => { location.href = dest; }, 800);
158-
return true;
159237
}
160238

161239
function wire() {
162240
const button = document.querySelector('#signin');
163241
if (!button) return;
164242
button.addEventListener('click', async () => {
165243
button.disabled = true;
166-
const ok = await flow();
244+
const ok = await freshFlow();
167245
if (!ok) button.disabled = false;
168246
});
169247
}
170248

171-
wire();
249+
// On page load: if URL has ?code=, we're returning from the IdP —
250+
// finalize the session. Otherwise wire up the button for a fresh
251+
// sign-in.
252+
if (new URLSearchParams(location.search).has('code')) {
253+
returnFlow();
254+
} else {
255+
wire();
256+
}

0 commit comments

Comments
 (0)