Skip to content

Commit

Permalink
feat(security): integrate joining community via Solid community inbox…
Browse files Browse the repository at this point in the history
… service (solidcouch#131)

Compatible with https://github.com/solidcouch/community-inbox/releases/tag/v0.3.1

Joining happens by POSTing "Join" activity to the community inbox.
The inbox endpoint must be linked from the community URI via `ldp:inbox`.
The inbox service must have read and write access to the relevant groups.

We are introducing a new, safer mechanism for joining:
Authenticated agents no longer require Append access to the relevant groups.
This Append access is a security vulnerability and should be removed.

This work also opens a possibility for leaving the community (TODO).
  • Loading branch information
mrkvon authored Jan 17, 2025
1 parent 80eec8e commit a90c8e5
Show file tree
Hide file tree
Showing 37 changed files with 371 additions and 61 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added

- Add dark theme
- Integrate joining community via [community inbox service](https://github.com/solidcouch/community-inbox)

### Changed

Expand Down
106 changes: 102 additions & 4 deletions cypress/e2e/setup.cy.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,16 @@
import { Parser, Store } from 'n3'
import { sioc, solid, space } from 'rdf-namespaces'
import { processAcl } from '../../src/utils/helpers'
import { UserConfig } from '../support/css-authentication'
import { CommunityConfig, SkipOptions } from '../support/setup'
import { foaf, ldp, sioc, solid, space, vcard } from 'rdf-namespaces'
import { processAcl, removeHashFromURI } from '../../src/utils/helpers'
import {
getAuthenticatedFetch,
UserConfig,
} from '../support/css-authentication'
import { generateAcl } from '../support/helpers/acl'
import {
CommunityConfig,
SkipOptions,
throwIfResponseNotOk,
} from '../support/setup'

const preparePod = () => {
cy.createRandomAccount().as('user1')
Expand Down Expand Up @@ -85,6 +93,96 @@ describe('Setup Solid pod', () => {
})
})

context('community not joined (new join service)', () => {
const inboxUrl = `https://inbox.community.org/inbox`

beforeEach(() => {
cy.get<CommunityConfig>('@community')
.then(com => {
// add an inbox to the community
cy.authenticatedRequest(com.user, {
url: com.community,
method: 'PATCH',
headers: { 'content-type': 'text/n3' },
body: `
_:addInbox a <${solid.InsertDeletePatch}>;
<${solid.inserts}> { <${com.community}> <${ldp.inbox}> <${inboxUrl}>. }.
`,
})

// change access rights of the group to read only
const resource = (() => {
const url = new URL(com.group)
url.hash = ''
return url.toString()
})()
const groupAcl = generateAcl(resource, [
{
permissions: ['Read', 'Write', 'Append', 'Control'],
agents: [com.user.webId],
},
{
permissions: ['Read', 'Write'],
agents: ['https://inbox.community.org/profile/card#bot'],
},
{ permissions: ['Read'], agentClasses: [foaf.Agent] },
])
cy.authenticatedRequest(com.user, {
url: resource + '.acl',
method: 'PUT',
body: groupAcl,
headers: { 'content-type': 'text/turtle' },
})

// mock requests to that inbox
cy.intercept<{
actor: { id: string }
}>('POST', inboxUrl, async req => {
const authFetch = await getAuthenticatedFetch(com.user)
const altRes = await authFetch(com.group, {
method: 'PATCH',
headers: { 'content-type': `text/n3` },
body: `_:insertPerson a <${solid.InsertDeletePatch}>;
<${solid.inserts}> { <${com.group}> <${vcard.hasMember}> <${req.body.actor.id}>. }.`,
})
await throwIfResponseNotOk(altRes)
return req.reply({
statusCode: 200,
headers: { location: com.group },
})
})
})
.as('joinActivity')
})

beforeEach(setupPod(['joinCommunity']))

it('should send a `Join` activity to community inbox', () => {
cy.get<CommunityConfig>('@community').then(community => {
cy.get<UserConfig>('@user1').then(user => {
cy.login(user)
cy.contains('button', 'Continue!')
cy.intercept('GET', removeHashFromURI(community.group)).as(
'groupUpdate',
)
cy.contains('button', 'Continue!').click()

// check that the activity was sent to inbox
cy.wait('@joinActivity')
.its('request.body')
.should('deep.nested.include', {
actor: { type: 'Person', id: user.webId },
object: { type: 'Group', id: community.community },
})

// check that the group was refetched afterwards
cy.wait('@groupUpdate')
})
})
cy.contains('a', 'travel')
})
})

context('personal hospex document for this community does not exist', () => {
beforeEach(setupPod(['personalHospexDocument']))
it('should create personal hospex document for this community', () => {
Expand Down
66 changes: 66 additions & 0 deletions cypress/support/css-authentication.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
} from '@inrupt/solid-client-authn-core'
import { buildAuthenticatedFetch } from './buildAuthenticatedFetch'
import { cyFetchWrapper, cyUnwrapFetch } from './css-authentication-helpers'
import { throwIfResponseNotOk } from './setup'

export interface UserConfig {
idp: string
Expand Down Expand Up @@ -144,3 +145,68 @@ export const getAuthenticatedRequest = (user: UserConfig) =>
const authRequest = cyUnwrapFetch(authFetchWrapper)
return cy.wrap(authRequest, { log: false })
})

// TODO replace with css-authn when it works in browser again
export const getAuthenticatedFetch = async (user: UserConfig) => {
const res1 = await fetch(new URL('/.account/', user.idp))
await throwIfResponseNotOk(res1)
const loginEndpoint = (await res1.json()).controls.password.login as string
const res2 = await fetch(loginEndpoint, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ email: user.email, password: user.password }),
})
await throwIfResponseNotOk(res2)
const authorization = (await res2.json()).authorization

const res3 = await fetch(new URL('/.account/', user.idp), {
headers: { authorization: `CSS-Account-Token ${authorization}` },
})
await throwIfResponseNotOk(res3)

const controls = (await res3.json()).controls

const res4 = await fetch(controls.account.clientCredentials, {
method: 'POST',
headers: {
authorization: `CSS-Account-Token ${authorization}`,
'content-type': 'application/json',
},
// The name field will be used when generating the ID of your token.
// The WebID field determines which WebID you will identify as when using the token.
// Only WebIDs linked to your account can be used.
body: JSON.stringify({ name: 'cypress-login-token', webId: user.webId }),
})
await throwIfResponseNotOk(res4)

const { id, secret } = await res4.json()

const tokenUrl = new URL('/.oidc/token', user.idp)
const dpopKey = await generateDpopKeyPair()
const dpop = await createDpopHeader(tokenUrl.toString(), 'POST', dpopKey)
const res5 = await fetch(tokenUrl, {
method: 'POST',
headers: {
// The header needs to be in base64 encoding.
authorization: `Basic ${btoa(
`${encodeURIComponent(id)}:${encodeURIComponent(secret)}`,
)}`,
'content-type': 'application/x-www-form-urlencoded',
dpop,
},
body: 'grant_type=client_credentials&scope=webid',
})
await throwIfResponseNotOk(res5)

const token = (await res5.json()).access_token

const authFetch = await buildAuthenticatedFetch(token, { dpopKey })

const resLogout = await fetch(controls.account.logout, {
method: 'POST',
headers: { authorization: `CSS-Account-Token ${authorization}` },
})
await throwIfResponseNotOk(resLogout)

return authFetch
}
45 changes: 45 additions & 0 deletions cypress/support/helpers/acl.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { acl } from 'rdf-namespaces'

interface AclConfig {
permissions: ('Read' | 'Write' | 'Append' | 'Control')[]
identifier?: string
agents?: string[]
agentGroups?: string[]
agentClasses?: string[]
isDefault?: boolean
}

const generateAuthorization = (resource: string, config: AclConfig) => {
config.identifier ??= config.permissions.join('')
const {
permissions,
agents,
agentGroups,
agentClasses,
isDefault,
identifier,
} = config

return `<#${identifier}> a <${acl.Authorization}>;
${
agents && agents.length > 0
? `<${acl.agent}> ${agents.map(a => `<${a}>`).join(', ')};`
: ''
}
${
agentGroups && agentGroups.length > 0
? `<${acl.agentGroup}> ${agentGroups.map(a => `<${a}>`).join(', ')};`
: ''
}
${
agentClasses && agentClasses.length > 0
? `<${acl.agentClass}> ${agentClasses.map(a => `<${a}>`).join(', ')};`
: ''
}
<${acl.accessTo}> <${resource}>;
${isDefault ? `<${acl.default__workaround}> <${resource}>;` : ''}
<${acl.mode}> ${permissions.map(p => `<${acl[p]}>`).join(', ')}.`
}

export const generateAcl = (resource: string, acls: AclConfig[]) =>
acls.map(config => generateAuthorization(resource, config)).join('\n\n')
16 changes: 7 additions & 9 deletions cypress/support/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -530,6 +530,13 @@ export const stubMailer = ({
}).as('simpleEmailNotification')
}

export const throwIfResponseNotOk = async (response: Response) => {
if (!response.ok)
throw new Error(
`Query was not successful: ${response.status} ${await response.text()}`,
)
}

const createAccountAsync =
(ifNotExist?: boolean) =>
async ({
Expand Down Expand Up @@ -557,15 +564,6 @@ const createAccountAsync =

const accountEndpoint = new URL('.account/account/', provider).toString()

const throwIfResponseNotOk = async (response: Response) => {
if (!response.ok)
throw new Error(
`Query was not successful: ${
response.status
} ${await response.text()}`,
)
}

// create the account
const response = await fetch(accountEndpoint, {
method: 'post',
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
"build:ldo": "ldo build --input src/shapes --output src/ldo",
"postbuild:ldo": "yarn format",
"cy:dev": "concurrently --kill-others \"yarn cy:dev:app\" \"yarn preview\" \"yarn cy:dev:css\" \"yarn cy:dev:open\"",
"cy:dev:app": "BROWSER=none VITE_COMMUNITY=http://localhost:4000/test-community/community#us VITE_EMAIL_NOTIFICATIONS_SERVICE=http://localhost:3005 VITE_ENABLE_DEV_CLIENT_ID=true VITE_EMAIL_NOTIFICATIONS_IDENTITY=http://localhost:4000/mailbot/profile/card#me VITE_EMAIL_NOTIFICATIONS_TYPE=simple VITE_GEOINDEX= yarn build --watch",
"cy:dev:app": "VITE_COMMUNITY=http://localhost:4000/test-community/community#us VITE_EMAIL_NOTIFICATIONS_SERVICE=http://localhost:3005 VITE_ENABLE_DEV_CLIENT_ID=true VITE_EMAIL_NOTIFICATIONS_IDENTITY=http://localhost:4000/mailbot/profile/card#me VITE_EMAIL_NOTIFICATIONS_TYPE=simple VITE_GEOINDEX= yarn build --watch",
"cy:dev:css": "community-solid-server -p 4000 -c ./cypress/css-config-no-log.json",
"cy:dev:open": "cypress open",
"knip": "knip"
Expand Down
9 changes: 8 additions & 1 deletion src/hooks/data/queries/community.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { RdfQuery } from '@ldhop/core'
import { sioc, vcard } from 'rdf-namespaces'
import { ldp, sioc, vcard } from 'rdf-namespaces'

export const readCommunityQuery: RdfQuery = [
{
Expand All @@ -9,6 +9,13 @@ export const readCommunityQuery: RdfQuery = [
pick: 'object',
target: '?group',
},
{
type: 'match',
subject: '?community',
predicate: ldp.inbox,
pick: 'object',
target: '?inbox',
},
]

export const readCommunityMembersQuery: RdfQuery = [
Expand Down
12 changes: 11 additions & 1 deletion src/hooks/data/useCommunity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,17 @@ export const useReadCommunity = (communityId: URI) => {
pun,
groups: variables.group ?? [],
isLoading,
inbox: variables.inbox?.[0],
}),
[about, community.logo, communityId, isLoading, name, pun, variables.group],
[
about,
community.logo,
communityId,
isLoading,
name,
pun,
variables.group,
variables.inbox,
],
)
}
59 changes: 58 additions & 1 deletion src/hooks/data/useJoinGroup.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,17 @@
import { URI } from '@/types'
import { HttpError } from '@/utils/errors'
import { removeHashFromURI } from '@/utils/helpers'
import { solid, vcard } from '@/utils/rdf-namespaces'
import { fetch } from '@inrupt/solid-client-authn-browser'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { useCallback } from 'react'
import { useUpdateRdfDocument } from './useRdfDocument'

export const useJoinGroup = () => {
/**
* Join community by appending membership triple directly to the group
* @deprecated This method of joining is unsafe, and will be removed in the future. Send Join activity to community inbox with `useJoinCommunity` instead.
*/
export const useJoinGroupLegacy = () => {
const updateMutation = useUpdateRdfDocument()
return useCallback(
async ({ person, group }: { person: URI; group: URI }) => {
Expand All @@ -17,3 +25,52 @@ export const useJoinGroup = () => {
[updateMutation],
)
}

type NotificationData = {
actor: string
object: string
type: 'Join'
}

const getNotificationBody = ({ actor, object, type }: NotificationData) => ({
'@context': 'https://www.w3.org/ns/activitystreams',
type,
actor: { type: 'Person', id: actor },
object: { type: 'Group', id: object },
})

const notifyCommunityInbox = async ({
inbox,
...data
}: NotificationData & { inbox: string }) => {
const response = await fetch(inbox, {
method: 'POST',
body: JSON.stringify(getNotificationBody(data)),
headers: { 'content-type': 'application/ld+json' },
})

if (!response.ok)
throw new HttpError('Community inbox responded with error', response)

const group = response.headers.get('location')

return { ...data, status: response.status, group }
}

/**
* Join community by sending "Join" activity to community inbox
*/
export const useJoinCommunity = () => {
const queryClient = useQueryClient()

const { mutateAsync } = useMutation({
mutationFn: notifyCommunityInbox,
onSuccess: async data => {
if (!data.group) return
await queryClient.invalidateQueries({
queryKey: ['rdfDocument', removeHashFromURI(data.group)],
})
},
})
return mutateAsync
}
Loading

0 comments on commit a90c8e5

Please sign in to comment.