Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add sso enforcement for invite signup #25808

Merged
merged 26 commits into from
Oct 31, 2024
Merged
Show file tree
Hide file tree
Changes from 21 commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
09eb327
Add sso enforcement for invite signup
zlwaterfield Oct 24, 2024
dc04f25
Update InviteSignup.tsx
zlwaterfield Oct 24, 2024
08ea827
Update UI snapshots for `chromium` (1)
github-actions[bot] Oct 24, 2024
8c9f853
Update UI snapshots for `chromium` (2)
github-actions[bot] Oct 24, 2024
39b1b4f
Update UI snapshots for `chromium` (1)
github-actions[bot] Oct 24, 2024
0b1ee25
Update UI snapshots for `chromium` (1)
github-actions[bot] Oct 24, 2024
4e2ada6
Update UI snapshots for `chromium` (1)
github-actions[bot] Oct 24, 2024
416a18f
Update UI snapshots for `chromium` (1)
github-actions[bot] Oct 24, 2024
98909d5
Add signup validation for sso enforcement
zlwaterfield Oct 28, 2024
b22f8d6
Update InviteSignup.stories.tsx
zlwaterfield Oct 28, 2024
c10b9ff
LINT
zlwaterfield Oct 28, 2024
037054a
Update UI snapshots for `chromium` (1)
github-actions[bot] Oct 28, 2024
223103d
Update query snapshots
github-actions[bot] Oct 28, 2024
8d87b2d
Update query snapshots
github-actions[bot] Oct 28, 2024
885ddaf
Update UI snapshots for `chromium` (2)
github-actions[bot] Oct 28, 2024
1c48106
LINT
zlwaterfield Oct 28, 2024
ef28610
remove test
zlwaterfield Oct 28, 2024
372cae7
Update query snapshots
github-actions[bot] Oct 28, 2024
35f9a7a
Update UI snapshots for `chromium` (1)
github-actions[bot] Oct 28, 2024
bdf4076
Update query snapshots
github-actions[bot] Oct 28, 2024
edb0408
Update UI snapshots for `chromium` (1)
github-actions[bot] Oct 28, 2024
b1a6a13
Improve the logic for SSO auth buttons
zlwaterfield Oct 29, 2024
654c527
Update Login.tsx
zlwaterfield Oct 30, 2024
cb3cb55
Update Login.tsx
zlwaterfield Oct 30, 2024
ba6ad87
Update UI snapshots for `chromium` (1)
github-actions[bot] Oct 30, 2024
edccf27
Merge branch 'master' into zach/add-sso-enforcement-invite-signup
zlwaterfield Oct 30, 2024
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
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified frontend/__snapshots__/scenes-other-invitesignup--cloud--dark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified frontend/__snapshots__/scenes-other-invitesignup--cloud--light.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
Expand Up @@ -109,12 +109,15 @@ export function SocialLoginButtons({
type SSOEnforcedLoginButtonProps = SocialLoginButtonProps &
Partial<LemonButtonWithoutSideActionProps> & {
email: string
} & {
actionText?: string
}

export function SSOEnforcedLoginButton({
provider,
email,
extraQueryParams,
actionText = 'Log in',
...props
}: SSOEnforcedLoginButtonProps): JSX.Element {
return (
Expand All @@ -130,7 +133,7 @@ export function SSOEnforcedLoginButton({
size="large"
{...props}
>
Log in with {SSO_PROVIDER_NAMES[provider]}
{actionText} with {SSO_PROVIDER_NAMES[provider]}
</LemonButton>
</SocialLoginLink>
)
Expand Down
33 changes: 33 additions & 0 deletions frontend/src/scenes/authentication/InviteSignup.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ const meta: Meta = {
post: {
'/api/signup': (_, __, ctx) => [ctx.delay(1000), ctx.status(200), ctx.json({ success: true })],
'/api/signup/1234': (_, __, ctx) => [ctx.delay(1000), ctx.status(200), ctx.json({ success: true })],
'/api/login/precheck': { sso_enforcement: null, saml_available: false },
},
}),
],
Expand Down Expand Up @@ -176,3 +177,35 @@ export const LoggedInWrongUser = (): JSX.Element => {
</div>
)
}

export const SSOEnforcedSaml = (): JSX.Element => {
useStorybookMocks({
post: {
'/api/login/precheck': { sso_enforcement: 'saml', saml_available: true },
},
})
useEffect(() => {
inviteSignupLogic.actions.prevalidateInvite('1234')
}, [])
return (
<div>
<div className="border-b border-t p-4 font-bold">HEADER AREA</div>
<InviteSignup />
</div>
)
}

export const SSOEnforcedGoogle = (): JSX.Element => {
useStorybookMocks({
post: { '/api/login/precheck': { sso_enforcement: 'google-oauth2', saml_available: false } },
})
useEffect(() => {
inviteSignupLogic.actions.prevalidateInvite('1234')
}, [])
return (
<div>
<div className="border-b border-t p-4 font-bold">HEADER AREA</div>
<InviteSignup />
</div>
)
}
135 changes: 86 additions & 49 deletions frontend/src/scenes/authentication/InviteSignup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,13 @@ import { Form } from 'kea-forms'
import { BridgePage } from 'lib/components/BridgePage/BridgePage'
import PasswordStrength from 'lib/components/PasswordStrength'
import SignupRoleSelect from 'lib/components/SignupRoleSelect'
import { SocialLoginButtons } from 'lib/components/SocialLoginButton/SocialLoginButton'
import { SocialLoginButtons, SSOEnforcedLoginButton } from 'lib/components/SocialLoginButton/SocialLoginButton'
import { IconChevronLeft, IconChevronRight } from 'lib/lemon-ui/icons'
import { LemonField } from 'lib/lemon-ui/LemonField'
import { Link } from 'lib/lemon-ui/Link'
import { ProfilePicture } from 'lib/lemon-ui/ProfilePicture'
import { SpinnerOverlay } from 'lib/lemon-ui/Spinner/Spinner'
import { useEffect } from 'react'
import { preflightLogic } from 'scenes/PreflightCheck/preflightLogic'
import { SceneExport } from 'scenes/sceneTypes'
import { urls } from 'scenes/urls'
Expand All @@ -19,6 +20,7 @@ import { userLogic } from 'scenes/userLogic'
import { PrevalidatedInvite } from '~/types'

import { ErrorCodes, inviteSignupLogic } from './inviteSignupLogic'
import { loginLogic } from './loginLogic'
import { SupportModalButton } from './SupportModalButton'

export const scene: SceneExport = {
Expand Down Expand Up @@ -193,6 +195,15 @@ function UnauthenticatedAcceptInvite({ invite }: { invite: PrevalidatedInvite })
const { isSignupSubmitting, validatedPassword } = useValues(inviteSignupLogic)
const { preflight } = useValues(preflightLogic)

const { precheck } = useActions(loginLogic)
const { precheckResponse, precheckResponseLoading, login } = useValues(loginLogic)

const areExtraFieldsHidden = precheckResponse.status === 'pending' || precheckResponse.sso_enforcement

useEffect(() => {
precheck({ email: invite.target_email })
}, [invite.target_email])

return (
<BridgePage
view="invites-signup"
Expand Down Expand Up @@ -221,49 +232,73 @@ function UnauthenticatedAcceptInvite({ invite }: { invite: PrevalidatedInvite })
<LemonField.Pure label="Email">
<LemonInput type="email" disabled value={invite?.target_email} />
</LemonField.Pure>
<LemonField
name="password"
label={
<div className="flex flex-1 items-center justify-between">
<span>Password</span>
<PasswordStrength validatedPassword={validatedPassword} />
</div>
}
>
<LemonInput
type="password"
className="ph-ignore-input"
data-attr="password"
placeholder="••••••••••"
autoComplete="new-password"
autoFocus={window.screen.width >= 768} // do not autofocus on small-width screens
disabled={isSignupSubmitting}
/>
</LemonField>
{!areExtraFieldsHidden && (
<>
<LemonField
name="password"
label={
<div className="flex flex-1 items-center justify-between">
<span>Password</span>
<PasswordStrength validatedPassword={validatedPassword} />
</div>
}
>
<LemonInput
type="password"
className="ph-ignore-input"
data-attr="password"
placeholder="••••••••••"
autoComplete="new-password"
autoFocus={window.screen.width >= 768} // do not autofocus on small-width screens
disabled={isSignupSubmitting}
/>
</LemonField>

<LemonField
name="first_name"
label="First Name"
help={
invite?.first_name ? 'Your name was provided in the invite, feel free to change it.' : undefined
}
>
<LemonInput data-attr="first_name" placeholder="Jane" />
</LemonField>
<LemonField
name="first_name"
label="First Name"
help={
invite?.first_name
? 'Your name was provided in the invite, feel free to change it.'
: undefined
}
>
<LemonInput data-attr="first_name" placeholder="Jane" />
</LemonField>

<SignupRoleSelect />
<SignupRoleSelect />
</>
)}

<LemonButton
type="primary"
status="alt"
htmlType="submit"
data-attr="password-signup"
loading={isSignupSubmitting}
center
fullWidth
>
Continue
</LemonButton>
{precheckResponse.status === 'pending' || !precheckResponse.sso_enforcement ? (
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are these conditionals correct? If the response status is pending do we want to show the "continue" button?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will test a few things and confirm / improve.

<LemonButton
type="primary"
status="alt"
htmlType="submit"
data-attr="password-signup"
fullWidth
center
loading={isSignupSubmitting || precheckResponseLoading}
size="large"
>
Continue
</LemonButton>
) : (
<SSOEnforcedLoginButton
provider={precheckResponse.sso_enforcement}
email={login.email}
actionText="Continue"
extraQueryParams={invite ? { invite_id: invite.id } : undefined}
/>
)}
{precheckResponse.saml_available && !precheckResponse.sso_enforcement && (
zlwaterfield marked this conversation as resolved.
Show resolved Hide resolved
<SSOEnforcedLoginButton
provider="saml"
email={login.email}
actionText="Continue"
extraQueryParams={invite ? { invite_id: invite.id } : undefined}
/>
)}
</Form>
<div className="mt-4 text-center text-muted">
Already have an account? <Link to="/login">Log in</Link>
Expand All @@ -279,14 +314,16 @@ function UnauthenticatedAcceptInvite({ invite }: { invite: PrevalidatedInvite })
</Link>
.
</div>
<SocialLoginButtons
className="mb-4"
title="Or sign in with"
caption={`Remember to log in with ${invite?.target_email}`}
captionLocation="bottom"
topDivider
extraQueryParams={invite ? { invite_id: invite.id } : undefined}
/>
{!areExtraFieldsHidden && (
<SocialLoginButtons
className="mb-4"
title="Or sign in with"
caption={`Remember to log in with ${invite?.target_email}`}
captionLocation="bottom"
topDivider
extraQueryParams={invite ? { invite_id: invite.id } : undefined}
/>
)}
</BridgePage>
)
}
Expand Down
8 changes: 8 additions & 0 deletions posthog/api/signup.py
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,14 @@ def create(self, validated_data, **kwargs):
except OrganizationInvite.DoesNotExist:
raise serializers.ValidationError("The provided invite ID is not valid.")

if invite.target_email and OrganizationDomain.objects.get_sso_enforcement_for_email_address(
invite.target_email
):
raise serializers.ValidationError(
"Sign up with a password is disabled because SSO login is enforced for this domain. Please log in with your SSO credentials.",
code="sso_enforced",
)

with transaction.atomic():
if not user:
is_new_user = True
Expand Down
40 changes: 40 additions & 0 deletions posthog/api/test/test_signup.py
Original file line number Diff line number Diff line change
Expand Up @@ -1558,6 +1558,46 @@ def test_cant_claim_expired_invite(self):
self.assertEqual(Team.objects.count(), team_count)
self.assertEqual(Organization.objects.count(), org_count)

def test_api_signup_with_sso_enforced_fails(self):
"""Test that users cannot sign up with email/password when SSO is enforced."""

organization = Organization.objects.create(name="Test Org")
organization.available_product_features = [
{"key": AvailableFeature.SSO_ENFORCEMENT, "name": AvailableFeature.SSO_ENFORCEMENT},
{"key": AvailableFeature.SAML, "name": AvailableFeature.SAML},
]
organization.save()
OrganizationDomain.objects.create(
domain="posthog_sss_test.com", organization=organization, sso_enforcement="saml", verified_at=timezone.now()
)

invite: OrganizationInvite = OrganizationInvite.objects.create(
target_email="test+sso@posthog_sss_test.com", organization=organization
)

response = self.client.post(
f"/api/signup/{invite.id}/",
{
"first_name": "Alice",
"password": VALID_TEST_PASSWORD,
},
)

self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertEqual(
response.json(),
{
"type": "validation_error",
"code": "sso_enforced",
"detail": "Sign up with a password is disabled because SSO login is enforced for this domain. Please log in with your SSO credentials.",
"attr": None,
},
)

# Verify no user was created and invite was not used
self.assertFalse(User.objects.filter(email="[email protected]").exists())
self.assertFalse(OrganizationInvite.objects.filter(target_email="[email protected]").exists())

# Social signup (use invite)

def test_api_social_invite_sign_up(self):
Expand Down
Loading