Skip to content
Open
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
40 changes: 30 additions & 10 deletions src/authn/SolidAuthnLogic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
//
Expand All @@ -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<unknown> | 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
Expand All @@ -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')
}
}
}

Expand Down Expand Up @@ -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<string | null> {
if (typeof window === 'undefined') {
return null
Expand Down
32 changes: 32 additions & 0 deletions src/logic/solidLogic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> | 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,
Expand Down
63 changes: 62 additions & 1 deletion test/solidAuthLogic.test.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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<NamedNode>()

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<void>(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', () => {
Expand Down
Loading