4bba529ba8
Support TOTP 2fa, allow for changing password, add session management and account removal.
256 lines
8.0 KiB
TypeScript
256 lines
8.0 KiB
TypeScript
import type { BetterAuthClientOptions, InferSessionFromClient, InferUserFromClient, User } from 'better-auth/client';
|
|
import { twoFactorClient } from 'better-auth/client/plugins';
|
|
import { createAuthClient } from 'better-auth/vue';
|
|
import { Err, Ok, type Result } from '~~/types/result';
|
|
import { assert } from '~~/utils/assert';
|
|
|
|
export enum AuthError {
|
|
NotAuthenticated = 'NOT_AUTHENTICATED',
|
|
SignInFailed = 'SIGN_IN_FAILED',
|
|
SignUpFailed = 'SIGN_UP_FAILED',
|
|
SignOutFailed = 'SIGN_OUT_FAILED',
|
|
UpdateFailed = 'UPDATE_FAILED',
|
|
NetworkError = 'NETWORK_ERROR',
|
|
}
|
|
|
|
export type AuthUser = InferUserFromClient<BetterAuthClientOptions> & {
|
|
twoFactorEnabled?: boolean | null;
|
|
};
|
|
|
|
type sessionData = {
|
|
session: InferSessionFromClient<BetterAuthClientOptions> | null;
|
|
user: AuthUser | null;
|
|
};
|
|
|
|
// Signing in either finishes right away, or stops at the second factor when
|
|
// the account has two-factor authentication turned on.
|
|
export type SignInResult =
|
|
| { twoFactorRequired: false; user: User; token: string }
|
|
| { twoFactorRequired: true };
|
|
|
|
export const useAuth = () => {
|
|
const authClient = createAuthClient({
|
|
plugins: [twoFactorClient()],
|
|
});
|
|
const session = useState<InferSessionFromClient<BetterAuthClientOptions> | null>('auth:session', () => null);
|
|
const user = useState<AuthUser | null>('auth:user', () => null);
|
|
const sessionFetching = import.meta.server ? ref(false) : useState('auth:sessionFetching', () => false);
|
|
|
|
let sessionPromise: Promise<Result<sessionData, AuthError>> | null = null;
|
|
|
|
const fetchSession = async (): Promise<Result<sessionData, AuthError>> => {
|
|
if (sessionPromise) {
|
|
console.log('already fetching session');
|
|
return sessionPromise;
|
|
}
|
|
|
|
sessionFetching.value = true;
|
|
const { promise, resolve } = Promise.withResolvers<Result<sessionData, AuthError>>();
|
|
sessionPromise = promise;
|
|
let data: {
|
|
session: InferSessionFromClient<BetterAuthClientOptions>;
|
|
user: AuthUser;
|
|
} | null = null;
|
|
|
|
try {
|
|
if (import.meta.server) {
|
|
data =
|
|
(
|
|
await useFetch<{
|
|
session: InferSessionFromClient<BetterAuthClientOptions>;
|
|
user: AuthUser;
|
|
}>('/api/auth/get-session')
|
|
).data.value ?? null;
|
|
} else {
|
|
data = (await authClient.getSession()).data;
|
|
}
|
|
|
|
session.value = data?.session || null;
|
|
user.value = data?.user || null;
|
|
resolve(Ok({ session: data?.session || null, user: data?.user || null }));
|
|
} catch (error) {
|
|
console.error('Failed to fetch session:', error);
|
|
resolve(Err(AuthError.NetworkError));
|
|
} finally {
|
|
sessionFetching.value = false;
|
|
}
|
|
|
|
return sessionPromise;
|
|
};
|
|
|
|
if (import.meta.client) {
|
|
authClient.$store.listen('$sessionSignal', async (signal) => {
|
|
if (!signal) return;
|
|
await fetchSession();
|
|
});
|
|
}
|
|
|
|
const signIn = async (
|
|
email: string,
|
|
password: string
|
|
): Promise<Result<SignInResult, { error: AuthError, data?: any }>> => {
|
|
sessionFetching.value = true;
|
|
|
|
try {
|
|
const { data, error } = await authClient.signIn.email({
|
|
email,
|
|
password,
|
|
});
|
|
|
|
if (error) {
|
|
console.error('Sign in failed:', error);
|
|
return Err({ error: AuthError.SignInFailed, data: error });
|
|
}
|
|
|
|
if (!data) {
|
|
return Err({ error: AuthError.SignInFailed });
|
|
}
|
|
|
|
// no session exists yet, verifyTwoFactor() finishes the sign in
|
|
if ('twoFactorRedirect' in data && data.twoFactorRedirect) {
|
|
return Ok({ twoFactorRequired: true });
|
|
}
|
|
|
|
user.value = data.user;
|
|
|
|
clearNuxtData();
|
|
|
|
return Ok({ twoFactorRequired: false, user: data.user, token: data.token });
|
|
} catch (err) {
|
|
console.error('Sign in error:', err);
|
|
return Err({ error: AuthError.NetworkError });
|
|
} finally {
|
|
sessionFetching.value = false;
|
|
}
|
|
};
|
|
|
|
const verifyTwoFactor = async (
|
|
code: string,
|
|
options: { backupCode?: boolean; trustDevice?: boolean } = {}
|
|
): Promise<Result<{ user: User; token: string }, { error: AuthError, data?: any }>> => {
|
|
sessionFetching.value = true;
|
|
|
|
try {
|
|
const body = { code, trustDevice: options.trustDevice ?? false };
|
|
const { data, error } = options.backupCode
|
|
? await authClient.twoFactor.verifyBackupCode(body)
|
|
: await authClient.twoFactor.verifyTotp(body);
|
|
|
|
if (error) {
|
|
return Err({ error: AuthError.SignInFailed, data: error });
|
|
}
|
|
|
|
if (!data || !data.token) {
|
|
return Err({ error: AuthError.SignInFailed });
|
|
}
|
|
|
|
user.value = data.user as AuthUser;
|
|
|
|
clearNuxtData();
|
|
|
|
return Ok({ user: data.user as User, token: data.token });
|
|
} catch (err) {
|
|
console.error('Two-factor verification error:', err);
|
|
return Err({ error: AuthError.NetworkError });
|
|
} finally {
|
|
sessionFetching.value = false;
|
|
}
|
|
};
|
|
|
|
const signUp = async (
|
|
email: string,
|
|
password: string,
|
|
name: string
|
|
): Promise<Result<{ user: User; token: string }, { error: AuthError, data?: any }>> => {
|
|
sessionFetching.value = true;
|
|
|
|
try {
|
|
const { data, error } = await authClient.signUp.email({
|
|
email,
|
|
password,
|
|
name,
|
|
});
|
|
|
|
if (error) {
|
|
console.error('Sign up failed:', error);
|
|
return Err({ error: AuthError.SignUpFailed, data: error });
|
|
}
|
|
|
|
if (!data || !data.token) {
|
|
return Err({ error: AuthError.SignUpFailed });
|
|
}
|
|
|
|
user.value = data.user;
|
|
|
|
clearNuxtData();
|
|
|
|
return Ok({ user: data.user, token: data.token });
|
|
} catch (err) {
|
|
console.error('Sign up error:', err);
|
|
return Err({ error: AuthError.NetworkError });
|
|
} finally {
|
|
sessionFetching.value = false;
|
|
}
|
|
};
|
|
|
|
const signOut = async (): Promise<Result<void, AuthError>> => {
|
|
sessionFetching.value = true;
|
|
|
|
try {
|
|
const { error } = await authClient.signOut();
|
|
|
|
if (error) {
|
|
console.error('Sign out failed:', error);
|
|
return Err(AuthError.SignOutFailed);
|
|
}
|
|
|
|
user.value = null;
|
|
session.value = null;
|
|
|
|
clearNuxtData();
|
|
|
|
return Ok(undefined);
|
|
} catch (err) {
|
|
console.error('Sign out error:', err);
|
|
return Err(AuthError.NetworkError);
|
|
} finally {
|
|
sessionFetching.value = false;
|
|
}
|
|
};
|
|
|
|
const updateProfile = async (
|
|
updates: { name?: string; image?: string | null }
|
|
): Promise<Result<void, AuthError>> => {
|
|
try {
|
|
const { error } = await authClient.updateUser(updates);
|
|
|
|
if (error) {
|
|
console.error('Profile update failed:', error);
|
|
return Err(AuthError.UpdateFailed);
|
|
}
|
|
|
|
if (user.value) {
|
|
user.value = { ...user.value, ...updates };
|
|
}
|
|
|
|
return Ok(undefined);
|
|
} catch (err) {
|
|
console.error('Profile update error:', err);
|
|
return Err(AuthError.NetworkError);
|
|
}
|
|
};
|
|
|
|
return {
|
|
client: authClient,
|
|
session,
|
|
user,
|
|
loggedIn: computed(() => !!session.value),
|
|
signIn,
|
|
verifyTwoFactor,
|
|
signUp,
|
|
signOut,
|
|
updateProfile,
|
|
fetchSession,
|
|
};
|
|
};
|