diff --git a/packages/api/src/routes/user.ts b/packages/api/src/routes/user.ts index e18711027..465fd76ff 100644 --- a/packages/api/src/routes/user.ts +++ b/packages/api/src/routes/user.ts @@ -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({ ctx, @@ -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 @@ -123,22 +117,16 @@ const validateRedirectDomain = (ctx: ApiContextProps, redirectTo?: string) => { ) } -const signIn = async ({ - ctx, - input, -}: { - ctx: ApiContextProps - input: Input -}) => { - let res: SignInResult = {} - if (input.provider && input.idToken) { +const signInWithAppleIdTokenHandler = + (ctx: ApiContextProps) => + async (input: Input & { 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' || @@ -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 & { provider: AuthProviderName; code: string }) => { // Handling OAuth callback after user has authenticated with provider if (!ctx.c) { throw new TRPCError({ @@ -210,7 +202,7 @@ 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, @@ -218,7 +210,11 @@ const signIn = async ({ storedState, storedRedirect ) - } else if (input.provider) { + } + +const authorizationUrlHandler = + (ctx: ApiContextProps) => + async (input: Input & { provider: AuthProviderName }) => { // Get the authorization URL and store the state in a cookie const provider = getAuthProvider(ctx, input.provider) const state = generateState() @@ -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 & { 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 +}) => { + return await match(input) + .returnType>() + .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.', diff --git a/packages/app/utils/auth/index.ts b/packages/app/utils/auth/index.ts index 752ed3d5a..51568daa7 100644 --- a/packages/app/utils/auth/index.ts +++ b/packages/app/utils/auth/index.ts @@ -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 @@ -139,9 +140,11 @@ 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 { @@ -149,9 +152,9 @@ export function isSignInWithEmailAndCode(props: SignInProps): props is SignInWit 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 { @@ -159,16 +162,9 @@ export function isSignInWithPhoneAndCode(props: SignInProps): props is SignInWit 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( @@ -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? @@ -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 }