Skip to content

Commit

Permalink
Merge pull request #108 from rmarscher/lucia-auth
Browse files Browse the repository at this point in the history
Refactor some auth code with ts-pattern
  • Loading branch information
timothymiller authored Nov 16, 2023
2 parents c07d351 + 2947b39 commit 0c1b6d6
Show file tree
Hide file tree
Showing 2 changed files with 117 additions and 96 deletions.
117 changes: 67 additions & 50 deletions packages/api/src/routes/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,11 @@ import { GetByIdSchema, GetSessionsSchema } from '../schema/shared'
import { CreateUserSchema, SignInSchema } from '../schema/user'
import { AppleIdTokenClaims, generateCodeVerifier, generateState } from 'arctic'
import { getAuthProvider, getUserFromAuthProvider } from '../auth/shared'
import { getPayloadFromJWT, isJWTExpired, sha256 } from '../utils/crypto'
import { verifyToken, isJWTExpired, sha256 } from '../utils/crypto'
import { getCookie } from 'hono/cookie'
import { JWT, parseJWT } from '../utils/jwt'
import { P, match } from 'ts-pattern'
import { AuthProviderName } from '../auth/providers'

export function sanitizeUserIdInput<K extends keyof T, T>({
ctx,
Expand Down Expand Up @@ -104,14 +106,6 @@ async function getUser({
}
}

interface AppleIdTokenPayload {
nonce?: string
nonce_supported?: string
sub: string
email?: string
email_verified?: boolean
}

const validateRedirectDomain = (ctx: ApiContextProps, redirectTo?: string) => {
if (!redirectTo) {
return true
Expand All @@ -123,22 +117,16 @@ const validateRedirectDomain = (ctx: ApiContextProps, redirectTo?: string) => {
)
}

const signIn = async ({
ctx,
input,
}: {
ctx: ApiContextProps
input: Input<typeof SignInSchema>
}) => {
let res: SignInResult = {}
if (input.provider && input.idToken) {
const signInWithAppleIdTokenHandler =
(ctx: ApiContextProps) =>
async (input: Input<typeof SignInSchema> & { provider: AuthProviderName; idToken: string }) => {
// This supports native sign-in with apple
//
// It could be possible to fetch the Apple public RSA key and verify the JWT.
// However, cloudflare workers webcrypto threw in error during testing:
// `Unrecognized key import algorithm "RS256" requested`
// https://developers.cloudflare.com/workers/runtime-apis/web-crypto/#supported-algorithms
// const payload = (await getPayloadFromJWT(input.idToken, async (payload: JWT) => {
// const payload = (await verifyToken(input.idToken, async (payload: JWT) => {
// if (
// !('kid' in payload.header) ||
// typeof payload.header.kid !== 'string' ||
Expand Down Expand Up @@ -198,8 +186,12 @@ const signIn = async ({
})
const session = await createSession(ctx.auth, user.id)
ctx.authRequest?.setSessionCookie(session.id)
res.session = session
} else if (input.provider && input.code) {
return { session }
}

const signInWithOAuthCodeHandler =
(ctx: ApiContextProps) =>
async (input: Input<typeof SignInSchema> & { provider: AuthProviderName; code: string }) => {
// Handling OAuth callback after user has authenticated with provider
if (!ctx.c) {
throw new TRPCError({
Expand All @@ -210,15 +202,19 @@ const signIn = async ({

const storedState = getCookie(ctx.c, `${input.provider}_oauth_state`)
const storedRedirect = getCookie(ctx.c, `${input.provider}_oauth_redirect`)
res = await signInWithOAuthCode(
return await signInWithOAuthCode(
ctx,
input.provider,
input.code,
input.state,
storedState,
storedRedirect
)
} else if (input.provider) {
}

const authorizationUrlHandler =
(ctx: ApiContextProps) =>
async (input: Input<typeof SignInSchema> & { provider: AuthProviderName }) => {
// Get the authorization URL and store the state in a cookie
const provider = getAuthProvider(ctx, input.provider)
const state = generateState()
Expand All @@ -235,42 +231,63 @@ const signIn = async ({
ctx.setCookie(
`${input.provider}_oauth_redirect=${input.redirectTo || ''}; Path=/; HttpOnly; SameSite=Lax`
)
res.redirectTo = url.toString()
return { redirectTo: url.toString() }
}
if (input.email) {
if (input.code) {
res = await signInWithCode(ctx, 'email', input.email, input.code, ctx.setCookie)
if (res.session?.userId && input.password) {
// If the user is also resetting their password,
// update the password, invalidate all sessions, create a new session and return it
updatePassword(ctx, input.email, input.password)
console.log('calling update passing and invalidate sessions')
await ctx.auth.invalidateUserSessions(res.session?.userId)
const session = await createSession(ctx.auth, res.session?.userId)
ctx.authRequest?.setSessionCookie(session.id)
res.session = session
}
} else if (input.password) {
res = await signInWithEmail(ctx, input.email, input.password, ctx.setCookie)
} else {
res = await sendEmailSignIn(ctx, input.email)

const signInWithEmailCodeHandler =
(ctx: ApiContextProps) =>
async (input: Input<typeof SignInSchema> & { email: string; code: string }) => {
const res = await signInWithCode(ctx, 'email', input.email, input.code, ctx.setCookie)
if (res.session?.userId && input.password) {
// If the user is also resetting their password,
// update the password, invalidate all sessions, create a new session and return it
updatePassword(ctx, input.email, input.password)
console.log('calling update passing and invalidate sessions')
await ctx.auth.invalidateUserSessions(res.session?.userId)
const session = await createSession(ctx.auth, res.session?.userId)
ctx.authRequest?.setSessionCookie(session.id)
res.session = session
}
// } else if (input.phone) {
// if (input.code) {
// res = await signInWithPhoneAndCode(ctx, input.phone, input.code, ctx.setCookie)
// } else {
// res = await sendPhoneSignIn(ctx, input.phone)
// }
return res
}
// console.log('log in result', res)
return res

const signIn = async ({
ctx,
input,
}: {
ctx: ApiContextProps
input: Input<typeof SignInSchema>
}) => {
return await match(input)
.returnType<Promise<SignInResult>>()
.with({ provider: P.string, idToken: P.string }, signInWithAppleIdTokenHandler(ctx))
.with({ provider: P.string, code: P.string }, signInWithOAuthCodeHandler(ctx))
.with({ provider: P.string }, authorizationUrlHandler(ctx))
.with({ email: P.string, code: P.string }, signInWithEmailCodeHandler(ctx))
.with({ email: P.string, password: P.string }, async (input) => {
return await signInWithEmail(ctx, input.email, input.password, ctx.setCookie)
})
.with({ email: P.string }, async (input) => {
return await sendEmailSignIn(ctx, input.email)
})
// .with({ phone: P.string, code: P.string }, async (input) => {
// return await signInWithPhoneAndCode(ctx, input.phone, input.code, ctx.setCookie)
// .with({ phone: P.string }, async (input) => {
// return await sendPhoneSignIn(ctx, input.phone)
// })
.otherwise(() => {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Invalid sign in request.',
})
})
}

export const userRouter = router({
signIn: publicProcedure.input(valibotParser(SignInSchema)).mutation(signIn),

signOut: protectedProcedure.mutation(async ({ ctx }) => {
if (!ctx.user?.id) {
if (!ctx.session?.id) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'You must be signed in to sign out.',
Expand Down
96 changes: 50 additions & 46 deletions packages/app/utils/auth/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import type { AuthProviderName } from '@t4/api/src/auth/providers'
import { CreateUserSchema } from '@t4/api/src/schema/user'
import { isWeb } from '@t4/ui/src'
import type { User } from '@t4/api/src/db/schema'
import { match } from 'ts-pattern'

export const AUTH_SERVICE: 'lucia' | 'supabase' = 'lucia'
// ^ We could maybe configure which auth service to use
Expand Down Expand Up @@ -139,36 +140,31 @@ export type SignInProps =
| SignInWithAppleIdTokenAndNonce
| SignInWithOAuth

export function isSignInWithEmail(props: SignInProps): props is SignInWithEmail {
export function isSignInWithEmailAndPassword(
props: SignInProps
): props is SignInWithEmailAndPassword {
// biome-ignore lint/complexity/useLiteralKeys: Index access needed for type guard
return props['email'] && !props['password']
return props['email'] && props['password']
}

export function isSignInWithEmailAndCode(props: SignInProps): props is SignInWithEmailAndCode {
// biome-ignore lint/complexity/useLiteralKeys: Index access needed for type guard
return props['email'] && props['code']
}

export function isSignInWithPhone(props: SignInProps): props is SignInWithPhone {
export function isSignInWithEmail(props: SignInProps): props is SignInWithEmail {
// biome-ignore lint/complexity/useLiteralKeys: Index access needed for type guard
return props['phone'] && !props['password']
return props['email'] && !props['password']
}

export function isSignInWithPhoneAndCode(props: SignInProps): props is SignInWithPhoneAndCode {
// biome-ignore lint/complexity/useLiteralKeys: Index access needed for type guard
return props['phone'] && props['code']
}

export function isSignInWithEmailAndPassword(
props: SignInProps
): props is SignInWithEmailAndPassword {
// biome-ignore lint/complexity/useLiteralKeys: Index access needed for type guard
return props['email'] && props['password']
}

export function isSignInWithOAuth(props: SignInProps): props is SignInWithOAuth {
export function isSignInWithPhone(props: SignInProps): props is SignInWithPhone {
// biome-ignore lint/complexity/useLiteralKeys: Index access needed for type guard
return props['provider'] && !props['idToken'] && !props['nonce']
return props['phone'] && !props['password']
}

export function isSignInWithAppleIdTokenAndNonce(
Expand All @@ -178,6 +174,11 @@ export function isSignInWithAppleIdTokenAndNonce(
return props['provider'] === 'apple' && props['idToken'] && props['nonce']
}

export function isSignInWithOAuth(props: SignInProps): props is SignInWithOAuth {
// biome-ignore lint/complexity/useLiteralKeys: Index access needed for type guard
return props['provider'] && !props['idToken'] && !props['nonce']
}

export function useSignIn() {
// TODO ^ maybe accept props for what to do after sign in?

Expand All @@ -197,40 +198,43 @@ export function useSignIn() {

// Might want to useCallback here and replace guards with ts-pattern
const signIn = async (props: SignInProps) => {
if (isSignInWithEmailAndPassword(props)) {
const res = await mutation.mutateAsync(props)
postLogin(res)
return res
}
if (isSignInWithAppleIdTokenAndNonce(props)) {
const res = await mutation.mutateAsync(props)
if (res.session) {
return await match(props)
.with({}, isSignInWithAppleIdTokenAndNonce, async (props) => {
const res = await mutation.mutateAsync(props)
if (res.session) {
postLogin(res)
}
return res
})
.with({}, isSignInWithOAuth, async (props) => {
return await mutation.mutateAsync(props)
})
.with({}, isSignInWithEmailAndPassword, async (props) => {
const res = await mutation.mutateAsync(props)
postLogin(res)
}
return res
}
if (isSignInWithOAuth(props)) {
return await mutation.mutateAsync(props)
}
if (isSignInWithEmail(props)) {
return await mutation.mutateAsync(props)
}
if (isSignInWithEmailAndCode(props)) {
const res = await mutation.mutateAsync(props)
postLogin(res)
return res
}
if (isSignInWithPhone(props)) {
throw new Error('Sign in with phone is not implemented yet')
// const res = await mutation.mutateAsync(props)
// TODO should switch to a view to enter the sms code
// return res
}
if (isSignInWithPhoneAndCode(props)) {
const res = await mutation.mutateAsync(props)
postLogin(res)
return res
}
return res
})
.with({}, isSignInWithEmailAndCode, async (props) => {
const res = await mutation.mutateAsync(props)
postLogin(res)
return res
})
.with({}, isSignInWithEmail, async (props) => {
return await mutation.mutateAsync(props)
})
.with({}, isSignInWithPhoneAndCode, async () => {
throw new Error('Sign in with phone is not implemented yet')
// const res = await mutation.mutateAsync(props)
// TODO should switch to a view to enter the sms code
// return res
})
.with({}, isSignInWithPhone, async () => {
throw new Error('Sign in with phone is not implemented yet')
// const res = await mutation.mutateAsync(props)
// TODO should switch to a view to enter the sms code
// return res
})
.exhaustive()
}

return { signIn, mutation }
Expand Down

0 comments on commit 0c1b6d6

Please sign in to comment.