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', () => {