Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
Button,
Chip,
ChipCombobox,
ChipConfirmModal,
ChipModal,
ChipModalBody,
ChipModalError,
Expand All @@ -17,9 +18,11 @@ import {
Label,
Switch,
Tooltip,
toast,
} from '@sim/emcn'
import { Camera, Check, CircleInfo, Pencil } from '@sim/emcn/icons'
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import dynamic from 'next/dynamic'
import Image from 'next/image'
import { useRouter } from 'next/navigation'
Expand Down Expand Up @@ -47,6 +50,7 @@ import { useBrandConfig } from '@/ee/whitelabeling'
import { useGeneralSettings, useUpdateGeneralSetting } from '@/hooks/queries/general-settings'
import {
useResetPassword,
useSignOutOtherDevices,
useUpdateUserProfile,
useUserProfile,
} from '@/hooks/queries/user-profile'
Expand Down Expand Up @@ -118,6 +122,8 @@ export function General() {
})
const [showResetPasswordModal, setShowResetPasswordModal] = useState(false)
const resetPassword = useResetPassword()
const [showSignOutOtherDevicesModal, setShowSignOutOtherDevicesModal] = useState(false)
const signOutOtherDevices = useSignOutOtherDevices()

const [showDeleteAccountModal, setShowDeleteAccountModal] = useState(false)

Expand Down Expand Up @@ -221,6 +227,14 @@ export function General() {
)
}

const confirmSignOutOtherDevices = () => {
signOutOtherDevices.mutate(undefined, {
onSuccess: () => toast.success('Signed out of other devices'),
onError: (error) => toast.error(getErrorMessage(error)),
onSettled: () => setShowSignOutOtherDevicesModal(false),
})
}

const handleThemeChange = async (value: string) => {
await updateSetting.mutateAsync({ key: 'theme', value: value as 'system' | 'light' | 'dark' })
}
Expand Down Expand Up @@ -288,6 +302,11 @@ export function General() {
...(session?.user?.id && !isAuthDisabled
? [
{ id: 'sign-out', text: 'Sign out', onSelect: () => signOutAndRedirect(router.push) },
{
id: 'sign-out-other-devices',
text: 'Sign out other devices',
onSelect: () => setShowSignOutOtherDevicesModal(true),
},
{
id: 'reset-password',
text: 'Reset password',
Expand Down Expand Up @@ -640,6 +659,19 @@ export function General() {
/>
</ChipModal>

<ChipConfirmModal
open={showSignOutOtherDevicesModal}
onOpenChange={setShowSignOutOtherDevicesModal}
title='Sign out other devices'
text='This device and your authorized apps stay signed in.'
confirm={{
label: 'Sign out',
onClick: confirmSignOutOtherDevices,
pending: signOutOtherDevices.isPending,
pendingLabel: 'Signing out...',
}}
/>

<DeleteAccountModal
open={showDeleteAccountModal}
onOpenChange={setShowDeleteAccountModal}
Expand Down
4 changes: 3 additions & 1 deletion apps/sim/ee/scim/lib/application/users/provision-user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { ScimUserAttributes } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { APIError } from 'better-auth/api'
import { auth } from '@/lib/auth'
import { revokeUserOAuthGrants } from '@/lib/auth/oauth-access-token'
import { applySessionPolicyToNewMember } from '@/lib/auth/session-policy'
import { syncUsageLimitsFromSubscription } from '@/lib/billing/core/usage'
import { ensureUserInOrganizationTx } from '@/lib/billing/organizations/membership'
Expand Down Expand Up @@ -184,8 +185,9 @@ export const provisionScimUser = defineAuthorizedScimUseCase({
email,
name: accountName(attributes),
})
if (emailChanged && attributes.active) {
if (emailChanged) {
await revokeUserSessionsTx(tx, { userId, organizationId: context.organizationId })
await revokeUserOAuthGrants(userId, tx)
}
/** A relinked account may still carry the suspension a lost deprovisioning left behind. */
if (attributes.active) await unsuspendMemberTx(tx, { userId, source: 'scim' })
Expand Down
18 changes: 9 additions & 9 deletions apps/sim/ee/scim/lib/application/users/update-user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { db } from '@sim/db'
import type { ScimUserAttributes } from '@sim/db/schema'
import { normalizeEmail } from '@sim/utils/string'
import type { ScimPatchOperation } from '@/lib/api/contracts/scim'
import { revokeUserOAuthGrants } from '@/lib/auth/oauth-access-token'
import { acquireOrganizationUserMutationLocks } from '@/lib/billing/organizations/membership'
import type { DbOrTx, DbTransaction } from '@/lib/db/types'
import type { StoppedRun } from '@/lib/mothership/async-runs/repository'
Expand Down Expand Up @@ -86,16 +87,15 @@ async function applyUserUpdate(
})

/**
* An address change ends the sessions established under the old one. A
* deactivation in the same request revokes them itself, so this only runs
* when nothing else will.
* An address change ends the sessions and OAuth grants established under
* the old one. Sessions go first: issuance references its session, so no
* grant can be minted behind the sweep.
*/
if (!deactivated) {
await revokeUserSessionsTx(tx, {
userId: current.userId,
organizationId: context.organizationId,
})
}
await revokeUserSessionsTx(tx, {
userId: current.userId,
organizationId: context.organizationId,
})
await revokeUserOAuthGrants(current.userId, tx)
} else if (nextName !== current.name) {
await syncAccountIdentityTx(tx, { userId: current.userId, name: nextName })
}
Expand Down
14 changes: 14 additions & 0 deletions apps/sim/hooks/queries/user-profile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
type UpdateUserProfileBody,
updateUserProfileContract,
} from '@/lib/api/contracts/user'
import { client } from '@/lib/auth/client'
import {
mapUserProfileResponse,
USER_PROFILE_STALE_TIME,
Expand Down Expand Up @@ -88,3 +89,16 @@ export function useResetPassword() {
},
})
}

/**
* Ends every session except this one. Better Auth's plugin endpoint is called through the auth
* client, as no Sim contract types it.
*/
export function useSignOutOtherDevices() {
return useMutation({
mutationFn: async () => {
const { error } = await client.revokeOtherSessions()
if (error) throw new Error(error.message ?? 'Failed to sign out other devices')
},
})
}
67 changes: 67 additions & 0 deletions apps/sim/scripts/test-scim-e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -543,6 +543,73 @@ async function run() {
}
)

await check(
'An address change ends sessions and OAuth grants issued under the old one',
async () => {
const carolEmail = `carol@${primary.domain}`
const created = await scim(token, '/Users', {
method: 'POST',
expected: 201,
body: {
schemas: [USER_SCHEMA],
userName: carolEmail,
active: true,
name: { givenName: 'Carol', familyName: 'Example' },
emails: [{ value: carolEmail, type: 'work', primary: true }],
},
})
const carolId = string(created.body.id)
const userId = string(
(await sql`select user_id from scim_user where id = ${carolId}`)[0]?.user_id
)
const sessionId = generateId()
const clientId = generateId()
await sql`insert into session (id, token, user_id, expires_at, created_at, updated_at)
values (${sessionId}, ${generateShortId()}, ${userId}, now() + interval '1 day', now(), now())`
await sql`insert into oauth_client (id, client_id, name, public, token_endpoint_auth_method, grant_types, redirect_uris)
values (${clientId}, ${clientId}, 'SCIM E2E OAuth client', true, 'none',
${['authorization_code', 'refresh_token']}, ${['http://127.0.0.1/callback']})`
try {
await sql`insert into oauth_consent (id, client_id, user_id, scopes, created_at, updated_at)
values (${generateId()}, ${clientId}, ${userId}, ${['api:read']}, now(), now())`
await sql`insert into oauth_token_family (id, client_id, user_id, session_id, created_at, expires_at)
values (${generateId()}, ${clientId}, ${userId}, ${sessionId}, now(), now() + interval '1 day')`
await sql`insert into oauth_access_token (id, token, client_id, user_id, scopes, created_at, expires_at)
values (${generateId()}, ${generateShortId()}, ${clientId}, ${userId}, ${['api:read']},
now(), now() + interval '1 hour')`

await patch(token, `/Users/${carolId}`, [
{ op: 'replace', path: 'name.givenName', value: 'Caroline' },
])
assert.equal(
(await sql`select id from oauth_access_token where user_id = ${userId}`).length,
1
)

await patch(token, `/Users/${carolId}`, [
{
op: 'replace',
path: 'emails[type eq "work"].value',
value: `carol-renamed@${primary.domain}`,
},
])

assert.equal((await sql`select id from session where user_id = ${userId}`).length, 0)
assert.equal(
(await sql`select id from oauth_token_family where user_id = ${userId}`).length,
0
)
assert.equal(
(await sql`select id from oauth_access_token where user_id = ${userId}`).length,
0
)
} finally {
await sql`delete from oauth_client where client_id = ${clientId}`
Comment thread
waleedlatif1 marked this conversation as resolved.
await sql`delete from "user" where id = ${userId}`
}
}
)

await check(
'Group CRUD supports Entra member PATCH, duplicates, foreign IDs, and membership-only PUT versions',
async () => {
Expand Down
Loading