Skip to content

Commit

Permalink
make third party auth generic
Browse files Browse the repository at this point in the history
  • Loading branch information
kentcdodds committed Aug 16, 2023
1 parent cb4b3e2 commit 1d00e19
Show file tree
Hide file tree
Showing 14 changed files with 332 additions and 141 deletions.
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,4 @@ SENTRY_DSN="your-dsn"
# if they aren't then the real github api will be attempted
GITHUB_CLIENT_ID="MOCK_GITHUB_CLIENT_ID"
GITHUB_CLIENT_SECRET="MOCK_GITHUB_CLIENT_SECRET"
GITHUB_TOKEN="MOCK_GITHUB_TOKEN"
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,14 @@ import { GITHUB_PROVIDER_NAME } from '../../utils/github-auth.server.ts'
import { invariant } from '../../utils/misc.tsx'
import { sessionStorage } from '../../utils/session.server.ts'
import { twoFAVerificationType } from '../settings+/profile.two-factor.tsx'
import { ROUTE_PATH, loader } from './auth.github.callback.ts'
import { loader } from './auth.$provider.callback.ts'

const ROUTE_PATH = '/auth/github/callback'
const PARAMS = { provider: 'github' }

test('a new user goes to onboarding', async () => {
const request = await setupRequest()
const response = await loader({ request, params: {}, context: {} }).catch(
const response = await loader({ request, params: PARAMS, context: {} }).catch(
e => e,
)
expect(response).toHaveRedirect('/onboarding/github')
Expand All @@ -36,7 +39,7 @@ test('when auth fails, send the user to login with a toast', async () => {
}),
)
const request = await setupRequest()
const response = await loader({ request, params: {}, context: {} }).catch(
const response = await loader({ request, params: PARAMS, context: {} }).catch(
e => e,
)
invariant(response instanceof Response, 'response should be a Response')
Expand All @@ -54,7 +57,7 @@ test('when auth fails, send the user to login with a toast', async () => {
test('when a user is logged in, it creates the connection', async () => {
const session = await setupUser()
const request = await setupRequest(session.id)
const response = await loader({ request, params: {}, context: {} })
const response = await loader({ request, params: PARAMS, context: {} })
expect(response).toHaveRedirect('/settings/profile/connections')
await expect(response).toSendToast(
expect.objectContaining({
Expand Down Expand Up @@ -86,7 +89,7 @@ test(`when a user is logged in and has already connected, it doesn't do anything
},
})
const request = await setupRequest(session.id)
const response = await loader({ request, params: {}, context: {} })
const response = await loader({ request, params: PARAMS, context: {} })
expect(response).toHaveRedirect('/settings/profile/connections')
expect(response).toSendToast(
expect.objectContaining({
Expand All @@ -100,7 +103,7 @@ test('when a user exists with the same email, create connection and make session
const email = primaryGitHubEmail.email.toLowerCase()
const { userId } = await setupUser({ ...createUser(), email })
const request = await setupRequest()
const response = await loader({ request, params: {}, context: {} })
const response = await loader({ request, params: PARAMS, context: {} })

expect(response).toHaveRedirect('/')

Expand Down Expand Up @@ -140,7 +143,7 @@ test('gives an error if the account is already connected to another user', async
})
const session = await setupUser()
const request = await setupRequest(session.id)
const response = await loader({ request, params: {}, context: {} })
const response = await loader({ request, params: PARAMS, context: {} })
expect(response).toHaveRedirect('/settings/profile/connections')
await expect(response).toSendToast(
expect.objectContaining({
Expand All @@ -162,7 +165,7 @@ test('if a user is not logged in, but the connection exists, make a session', as
},
})
const request = await setupRequest()
const response = await loader({ request, params: {}, context: {} })
const response = await loader({ request, params: PARAMS, context: {} })
expect(response).toHaveRedirect('/')
await expect(response).toHaveSessionForUser(userId)
})
Expand All @@ -185,7 +188,7 @@ test('if a user is not logged in, but the connection exists and they have enable
},
})
const request = await setupRequest()
const response = await loader({ request, params: {}, context: {} })
const response = await loader({ request, params: PARAMS, context: {} })
const searchParams = new URLSearchParams({
type: twoFAVerificationType,
target: userId,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,55 +4,36 @@ import {
getSessionExpirationDate,
getUserId,
} from '../../utils/auth.server.ts'
import { handleMockCallback } from '../../utils/connections.server.ts'
import { ProviderNameSchema, providerLabels } from '../../utils/connections.tsx'
import { prisma } from '../../utils/db.server.ts'
import { GITHUB_PROVIDER_NAME } from '../../utils/github-auth.server.ts'
import { combineHeaders } from '../../utils/misc.tsx'
import {
destroyRedirectToHeader,
getRedirectCookieValue,
} from '../../utils/redirect-cookie.server.ts'
import { sessionStorage } from '../../utils/session.server.ts'
import {
createToastHeaders,
redirectWithToast,
} from '../../utils/toast.server.ts'
import { verifySessionStorage } from '../../utils/verification.server.ts'
import { handleNewSession } from './login.tsx'
import {
githubIdKey,
onboardingEmailSessionKey,
prefilledProfileKey,
} from './onboarding_.github.tsx'

export const ROUTE_PATH = '/auth/github/callback'
providerIdKey,
} from './onboarding_.$provider.tsx'

const destroyRedirectTo = { 'set-cookie': destroyRedirectToHeader }

export async function loader({ request }: DataFunctionArgs) {
const reqUrl = new URL(request.url)
export async function loader({ request, params }: DataFunctionArgs) {
const providerName = ProviderNameSchema.parse(params.provider)
request = await handleMockCallback(providerName, request)
const redirectTo = getRedirectCookieValue(request)
debugger

// normally you *really* want to avoid including test/dev code in your source
// but this is one of those cases where it's worth it to make the dev
// experience better. The fact is it's basically impossible to test these
// kinds of integrations.
if (process.env.GITHUB_CLIENT_ID?.startsWith('MOCK_')) {
const cookieSession = await sessionStorage.getSession(
request.headers.get('cookie'),
)
const state = cookieSession.get('oauth2:state') ?? 'MOCK_STATE'
cookieSession.set('oauth2:state', state)
reqUrl.searchParams.set('state', state)
request.headers.set(
'cookie',
await sessionStorage.commitSession(cookieSession),
)
request = new Request(reqUrl.toString(), request)
}
const label = providerLabels[providerName]

const authResult = await authenticator
.authenticate(GITHUB_PROVIDER_NAME, request, { throwOnError: true })
.authenticate(providerName, request, { throwOnError: true })
.then(
data => ({ success: true, data }) as const,
error => ({ success: false, error }) as const,
Expand All @@ -64,7 +45,7 @@ export async function loader({ request }: DataFunctionArgs) {
'/login',
{
title: 'Auth Failed',
description: 'There was an error authenticating with GitHub.',
description: `There was an error authenticating with ${label}.`,
type: 'error',
},
{ headers: destroyRedirectTo },
Expand All @@ -87,7 +68,7 @@ export async function loader({ request }: DataFunctionArgs) {
'/settings/profile/connections',
{
title: 'Already Connected',
description: `Your "${profile.username}" GitHub account is already connected.`,
description: `Your "${profile.username}" ${label} account is already connected.`,
},
{ headers: destroyRedirectTo },
)
Expand All @@ -96,18 +77,18 @@ export async function loader({ request }: DataFunctionArgs) {
'/settings/profile/connections',
{
title: 'Already Connected',
description: `The "${profile.username}" GitHub account is already connected to another account.`,
description: `The "${profile.username}" ${label} account is already connected to another account.`,
},
{ headers: destroyRedirectTo },
)
}
}

// If we're already logged in, then link the GitHub account
// If we're already logged in, then link the account
if (userId) {
await prisma.connection.create({
data: {
providerName: GITHUB_PROVIDER_NAME,
providerName,
providerId: profile.id,
userId,
},
Expand All @@ -117,7 +98,7 @@ export async function loader({ request }: DataFunctionArgs) {
{
title: 'Connected',
type: 'success',
description: `Your "${profile.username}" GitHub account has been connected.`,
description: `Your "${profile.username}" ${label} account has been connected.`,
},
{ headers: destroyRedirectTo },
)
Expand All @@ -128,7 +109,7 @@ export async function loader({ request }: DataFunctionArgs) {
return makeSession({ request, userId: existingConnection.userId })
}

// if the github email matches a user in the db, then link the account and
// if the email matches a user in the db, then link the account and
// make a new session
const user = await prisma.user.findUnique({
select: { id: true },
Expand All @@ -137,7 +118,7 @@ export async function loader({ request }: DataFunctionArgs) {
if (user) {
await prisma.connection.create({
data: {
providerName: GITHUB_PROVIDER_NAME,
providerName,
providerId: profile.id,
userId: user.id,
},
Expand All @@ -147,7 +128,7 @@ export async function loader({ request }: DataFunctionArgs) {
{
headers: await createToastHeaders({
title: 'Connected',
description: `Your "${profile.username}" GitHub account has been connected.`,
description: `Your "${profile.username}" ${label} account has been connected.`,
}),
},
)
Expand All @@ -163,9 +144,9 @@ export async function loader({ request }: DataFunctionArgs) {
email: profile.email.toLowerCase(),
username: profile.username?.replace(/[^a-zA-Z0-9_]/g, '_').toLowerCase(),
})
verifySession.set(githubIdKey, profile.id)
verifySession.set(providerIdKey, profile.id)
const onboardingRedirect = [
'/onboarding/github',
`/onboarding/${providerName}`,
redirectTo ? new URLSearchParams({ redirectTo }) : null,
]
.filter(Boolean)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
import { redirect, type DataFunctionArgs } from '@remix-run/node'
import { authenticator } from '../../utils/auth.server.ts'
import { GITHUB_PROVIDER_NAME } from '../../utils/github-auth.server.ts'
import { handleMockAction } from '../../utils/connections.server.ts'
import { ProviderNameSchema } from '../../utils/connections.tsx'
import { getReferrerRoute } from '../../utils/misc.tsx'
import { getRedirectCookieHeader } from '../../utils/redirect-cookie.server.ts'

export async function loader() {
return redirect('/login')
}

export async function action({ request }: DataFunctionArgs) {
export async function action({ request, params }: DataFunctionArgs) {
const providerName = ProviderNameSchema.parse(params.provider)
const formData = await request.formData()
const rawRedirectTo = formData.get('redirectTo')
const redirectTo =
Expand All @@ -18,13 +20,9 @@ export async function action({ request }: DataFunctionArgs) {

const redirectToCookie = getRedirectCookieHeader(redirectTo)

if (process.env.GITHUB_CLIENT_ID?.startsWith('MOCK_')) {
return redirect(`/auth/github/callback?code=MOCK_CODE&state=MOCK_STATE`, {
headers: redirectToCookie ? { 'set-cookie': redirectToCookie } : {},
})
}
await handleMockAction(providerName, redirectToCookie)
try {
return await authenticator.authenticate(GITHUB_PROVIDER_NAME, request)
return await authenticator.authenticate(providerName, request)
} catch (error: unknown) {
if (error instanceof Response && redirectToCookie) {
error.headers.append('set-cookie', redirectToCookie)
Expand Down
33 changes: 14 additions & 19 deletions app/routes/_auth+/login.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ import {
requireAnonymous,
sessionKey,
} from '../../utils/auth.server.ts'
import {
ProviderConnectionForm,
providerNames,
} from '../../utils/connections.tsx'
import { prisma } from '../../utils/db.server.ts'
import {
combineResponseInits,
Expand Down Expand Up @@ -229,7 +233,6 @@ export async function action({ request }: DataFunctionArgs) {
export default function LoginPage() {
const actionData = useActionData<typeof action>()
const isPending = useIsPending()
const isGitHubSubmitting = useIsPending({ formAction: '/auth/github' })
const [searchParams] = useSearchParams()
const redirectTo = searchParams.get('redirectTo')

Expand Down Expand Up @@ -314,24 +317,16 @@ export default function LoginPage() {
</StatusButton>
</div>
</Form>
<Form
className="mt-5 flex items-center justify-center gap-2 border-t-2 border-border pt-3"
action="/auth/github"
method="POST"
>
<input
type="hidden"
name="redirectTo"
value={redirectTo ?? '/'}
/>
<StatusButton
type="submit"
className="w-full"
status={isGitHubSubmitting ? 'pending' : 'idle'}
>
Login with GitHub
</StatusButton>
</Form>
<div className="mt-5 flex flex-col gap-5 border-b-2 border-t-2 border-border py-3">
{providerNames.map(providerName => (
<ProviderConnectionForm
key={providerName}
type="Login"
providerName={providerName}
redirectTo={redirectTo}
/>
))}
</div>
<div className="flex items-center justify-center gap-2 pt-6">
<span className="text-muted-foreground">New here?</span>
<Link
Expand Down
Loading

0 comments on commit 1d00e19

Please sign in to comment.