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
2833const 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).
5254function 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' && / ^ h t t p s ? : \/ \/ / . 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 - 9 a - f ] { 64 } $ / . test ( pubkey ) ) {
106176 setStatus ( `Signer returned an invalid public key: ${ pubkey } ` , 'error' ) ;
107177 return false ;
108178 }
109179
110- // Step 3 — resolve 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' && / ^ h t t p s ? : \/ \/ / . 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
161239function 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