From 763778827dc5cdf0a41732e0b825e2d092aeaf1a Mon Sep 17 00:00:00 2001 From: Sharon Stratsianis Date: Mon, 7 Sep 2026 21:56:23 +1000 Subject: [PATCH] Fix the auth for the patch issue Prompt: various prompts and research to determine that the authSession event was not being sent and that we had to remove previous anonymous fetches so they can be refetched with authentication Co-authored-by: GPT-5.4 --- src/authn/SolidAuthnLogic.ts | 40 +++++++++++++++++------ src/logic/solidLogic.ts | 32 ++++++++++++++++++ test/solidAuthLogic.test.ts | 63 +++++++++++++++++++++++++++++++++++- 3 files changed, 124 insertions(+), 11 deletions(-) diff --git a/src/authn/SolidAuthnLogic.ts b/src/authn/SolidAuthnLogic.ts index 79ceb08..97ee2d5 100644 --- a/src/authn/SolidAuthnLogic.ts +++ b/src/authn/SolidAuthnLogic.ts @@ -111,10 +111,12 @@ export class SolidAuthnLogic implements AuthnLogic { const redirectUrl = new URL(window.location.href) redirectUrl.hash = '' if (typeof sessionAny?.handleIncomingRedirect === 'function') { + const wasActive = sessionAny?.isActive ?? Boolean(sessionAny?.webId) await sessionAny.handleIncomingRedirect({ restorePreviousSession: true, url: redirectUrl.href }) + this.emitSessionActivatedIfActivated(sessionAny, wasActive, 'login') } else { // uvdsl-style session (no handleIncomingRedirect): restore then handle redirect. // @@ -125,10 +127,12 @@ export class SolidAuthnLogic implements AuthnLogic { // fails before `onconnect` — the promise never settles and the login // UI would spin forever. Race it against a timeout and treat a stall // as "no previous session" so the page can render the login button. - const wasActive = sessionAny?.isActive ?? Boolean(sessionAny?.webId) if (typeof sessionAny?.restore === 'function') { + let restorePromise: Promise | null = null + const wasActive = sessionAny?.isActive ?? Boolean(sessionAny?.webId) try { - await withRestoreTimeout(sessionAny.restore()) + restorePromise = sessionAny.restore() + await withRestoreTimeout(restorePromise) } catch (error) { const message = error instanceof Error ? error.message : String(error) // A failed restore on an inactive session just means "no usable @@ -144,18 +148,20 @@ export class SolidAuthnLogic implements AuthnLogic { } debug.log(`Session restore failed, continuing logged-out: ${message}`) } - const isNowActive = sessionAny?.isActive ?? Boolean(sessionAny?.webId) - if (!wasActive && isNowActive) { - sessionAny.events?.emit('sessionRestore', window.location.href) + if (!this.emitSessionActivatedIfActivated(sessionAny, wasActive, 'sessionRestore') && restorePromise) { + // The restore promise can still settle after the timeout race above, + // e.g. when a slow worker replies late. Emit then as well, so the + // store invalidation and UI listeners see the session activate + // instead of the page silently keeping stale anonymous metadata. + Promise.resolve(restorePromise).then(() => { + this.emitSessionActivatedIfActivated(sessionAny, wasActive, 'sessionRestore') + }).catch(() => { + // Late rejections are already handled by the timeout path above. + }) } } if (typeof sessionAny?.handleRedirectFromLogin === 'function') { - const wasActive = sessionAny?.isActive ?? Boolean(sessionAny?.webId) await sessionAny.handleRedirectFromLogin() - const isNowActive = sessionAny?.isActive ?? Boolean(sessionAny?.webId) - if (!wasActive && isNowActive) { - sessionAny.events?.emit('login') - } } } @@ -207,6 +213,20 @@ export class SolidAuthnLogic implements AuthnLogic { return me } + private emitSessionActivatedIfActivated ( + sessionAny: any, + wasActive: boolean, + eventName: 'login' | 'sessionRestore' + ): boolean { + const isNowActive = sessionAny?.isActive ?? + Boolean(sessionAny?.webId ?? sessionAny?.info?.webId) + if (!wasActive && isNowActive) { + sessionAny.events?.emit(eventName, window.location.href) + return true + } + return false + } + private async probeNssCookieBackedWebId (): Promise { if (typeof window === 'undefined') { return null diff --git a/src/logic/solidLogic.ts b/src/logic/solidLogic.ts index 5150d92..5f7cb2e 100644 --- a/src/logic/solidLogic.ts +++ b/src/logic/solidLogic.ts @@ -62,6 +62,38 @@ export function createSolidLogic(specialFetch: { fetch: (url: any, requestInit: store.statements.slice().forEach(store.remove.bind(store)) } + // A session usually activates after documents have already been fetched + // anonymously. Those cached responses carry no write metadata, and rdflib will + // not re-request a document it has already marked done, so `editable()` stays + // unknown and every PATCH is refused. Dropping both lets the next load record + // authenticated headers, so rdflib's own reload path recovers on its own. + function invalidateAnonymousFetches() { + const updater = store.updater as any + if (typeof updater?.flagAuthorizationMetadata !== 'function') { + return + } + + updater.flagAuthorizationMetadata(store) + + const fetcher = store.fetcher as any + const requested = fetcher?.requested as Record | undefined + if (!requested) { + return + } + + Object.entries(requested).forEach(([uri, state]) => { + // rdflib stores in-flight requests as `true`; completed ones as + // 'done', 'redirected', or a numeric status such as 403. Every + // completed entry holds pre-auth metadata, so drop them all. + if (state !== true) { + delete requested[uri] + } + }) + } + + session.events?.on('login', invalidateAnonymousFetches) + session.events?.on('sessionRestore', invalidateAnonymousFetches) + return { store, authn, diff --git a/test/solidAuthLogic.test.ts b/test/solidAuthLogic.test.ts index aed3c05..4e648a5 100644 --- a/test/solidAuthLogic.test.ts +++ b/test/solidAuthLogic.test.ts @@ -1,8 +1,10 @@ -import { beforeEach, describe, expect, it } from 'vitest' +/** @vitest-environment jsdom */ +import { beforeEach, describe, expect, it, vi } from 'vitest' import { SolidAuthnLogic } from '../src/authn/SolidAuthnLogic' import { silenceDebugMessages } from './helpers/debugger' import { AuthenticationContext } from '../src/types' import { EventEmitter } from 'node:events' +import { NamedNode } from 'rdflib' silenceDebugMessages() let solidAuthnLogic: SolidAuthnLogic @@ -29,6 +31,65 @@ describe('SolidAuthnLogic', () => { it('runs', async () => { expect(await solidAuthnLogic.checkUser()).toEqual(null) }) + it('emits login when handleIncomingRedirect activates the session', async () => { + const emitted: Array<[string, unknown]> = [] + const loginSession = { + events: new EventEmitter(), + isActive: false, + webId: undefined as string | undefined, + info: undefined, + handleIncomingRedirect: vi.fn(async () => { + loginSession.isActive = true + loginSession.webId = 'https://alice.example.com/profile/card#me' + }) + } + loginSession.events.on('login', (url: unknown) => emitted.push(['login', url])) + + const logic = new SolidAuthnLogic(loginSession as any) + + const webId = await logic.checkUser() + + expect(webId?.uri).toBe('https://alice.example.com/profile/card#me') + expect(loginSession.handleIncomingRedirect).toHaveBeenCalledTimes(1) + expect(emitted).toEqual([['login', window.location.href]]) + }) + }) + + describe('late session restore', () => { + it('emits sessionRestore when restore settles after the timeout', async () => { + vi.useFakeTimers() + try { + let resolveRestore: () => void = () => {} + const slowSession = { + events: new EventEmitter(), + isActive: false, + webId: undefined as string | undefined, + restore () { + return new Promise(resolve => { + resolveRestore = () => { + slowSession.isActive = true + slowSession.webId = 'http://localhost:3100/sharon/profile/card#me' + resolve() + } + }) + } + } + const logic = new SolidAuthnLogic(slowSession as any) + const emitted: string[] = [] + slowSession.events.on('sessionRestore', (url: string) => emitted.push(url)) + + const check = logic.checkUser() + await vi.advanceTimersByTimeAsync(6000) + await check + expect(emitted).toEqual([]) + + resolveRestore() + await vi.advanceTimersByTimeAsync(0) + expect(emitted).toHaveLength(1) + } finally { + vi.useRealTimers() + } + }) }) describe('currentUser', () => {