Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
158 changes: 155 additions & 3 deletions packages/ra-supabase-core/src/authProvider.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,16 @@
import { Provider, SupabaseClient, User } from '@supabase/supabase-js';
import { Factor, Provider, SupabaseClient, User } from '@supabase/supabase-js';
import { AuthProvider, UserIdentity } from 'ra-core';
import { getSearchString } from './getSearchString';

export const supabaseAuthProvider = (
client: SupabaseClient,
{ getIdentity, getPermissions, redirectTo }: SupabaseAuthProviderOptions
{
getIdentity,
getPermissions,
redirectTo,
enforceMFA,
mfaAppFriendlyName,
}: SupabaseAuthProviderOptions
): SupabaseAuthProvider => {
const authProvider: SupabaseAuthProvider = {
async login(params) {
Expand All @@ -18,6 +24,33 @@ export const supabaseAuthProvider = (
throw error;
}

if (enforceMFA) {
const { data, error } =
await client.auth.mfa.getAuthenticatorAssuranceLevel();
if (error) {
throw error;
}
const { currentLevel, nextLevel } = data;
if (currentLevel === 'aal1') {
if (nextLevel === 'aal1') {
// User has not yet enrolled in MFA
return {
redirectTo: redirectTo
? `${redirectTo}/mfa-enroll`
: '/mfa-enroll',
};
}
if (nextLevel === 'aal2') {
// User has an MFA factor enrolled but has not verified it.
return {
redirectTo: redirectTo
? `${redirectTo}/mfa-challenge`
: '/mfa-challenge',
};
}
}
}

return;
}

Expand Down Expand Up @@ -125,6 +158,27 @@ export const supabaseAuthProvider = (
) {
return;
}
// Users are on the mfa-enroll page, nothing to do
if (
window.location.pathname === '/mfa-enroll' ||
window.location.hash.includes('#/mfa-enroll')
) {
return;
}
// Users are on the mfa-challenge page, nothing to do
if (
window.location.pathname === '/mfa-challenge' ||
window.location.hash.includes('#/mfa-challenge')
) {
return;
}
// Users are on the mfa-unenroll page, nothing to do
if (
window.location.pathname === '/mfa-unenroll' ||
window.location.hash.includes('#/mfa-unenroll')
) {
return;
}

const { access_token, refresh_token, type } = getUrlParams();
// Users have reset their password or have just been invited and must set a new password
Expand Down Expand Up @@ -166,7 +220,13 @@ export const supabaseAuthProvider = (
window.location.pathname === '/set-password' ||
window.location.hash.includes('#/set-password') ||
window.location.pathname === '/forgot-password' ||
window.location.hash.includes('#/forgot-password')
window.location.hash.includes('#/forgot-password') ||
window.location.pathname === '/mfa-enroll' ||
window.location.hash.includes('#/mfa-enroll') ||
window.location.pathname === '/mfa-challenge' ||
window.location.hash.includes('#/mfa-challenge') ||
window.location.pathname === '/mfa-unenroll' ||
window.location.hash.includes('#/mfa-unenroll')
) {
return;
}
Expand All @@ -179,6 +239,54 @@ export const supabaseAuthProvider = (
const permissions = await getPermissions(data.user);
return permissions;
},
async mfaEnroll({
factorType,
}: MFAEnrollParams): Promise<MFAEnrollResult> {
if (factorType === 'phone') {
throw new Error(
'Phone MFA is not supported yet. Please use TOTP instead.'
);
}
const { data, error } = await client.auth.mfa.enroll({
factorType,
friendlyName: mfaAppFriendlyName,
});
if (error) {
throw error;
}
return data;
},
async mfaUnenroll({
factorId,
}: MFAUnenrollParams): Promise<MFAUnenrollResult> {
const { data, error } = await client.auth.mfa.unenroll({
factorId,
});
if (error) {
throw error;
}
return data;
},
async mfaChallengeAndVerify({
factorId,
code,
}: MFAChallengeAndVerifyParams): Promise<MFAChallengeAndVerifyResult> {
const { data, error } = await client.auth.mfa.challengeAndVerify({
factorId,
code,
});
if (error) {
throw error;
}
return data;
},
async mfaListFactors(): Promise<MFAListFactorsResult> {
const { data, error } = await client.auth.mfa.listFactors();
if (error) {
throw error;
}
return data;
},
};

if (typeof getIdentity === 'function') {
Expand All @@ -202,6 +310,8 @@ export type SupabaseAuthProviderOptions = {
getIdentity?: GetIdentity;
getPermissions?: GetPermissions;
redirectTo?: string;
enforceMFA?: boolean;
mfaAppFriendlyName?: string;
};

type LoginWithEmailPasswordParams = {
Expand Down Expand Up @@ -240,6 +350,48 @@ export type ResetPasswordParams = {
captchaToken?: string;
};

export type MFAEnrollParams = {
factorType: 'totp' | 'phone';
};

export type MFAEnrollResult = {
id: string;
type: 'totp';
totp: {
qr_code: string;
secret: string;
uri: string;
};
friendly_name?: string;
};

export type MFAUnenrollParams = {
factorId: string;
};

export type MFAUnenrollResult = {
id: string;
};

export type MFAChallengeAndVerifyParams = {
factorId: string;
code: string;
};

export type MFAChallengeAndVerifyResult = {
access_token: string;
token_type: string;
expires_in: number;
refresh_token: string;
user: User;
};

export type MFAListFactorsResult = {
all: Factor[];
totp: Factor[];
phone: Factor[];
};

const getUrlParams = () => {
const searchStr = getSearchString();
const urlSearchParams = new URLSearchParams(searchStr);
Expand Down
4 changes: 4 additions & 0 deletions packages/ra-supabase-core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,7 @@ export * from './useRedirectIfAuthenticated';
export * from './useResetPassword';
export * from './useSetPassword';
export * from './useSupabaseAccessToken';
export * from './useMFAEnroll';
export * from './useMFAUnenroll';
export * from './useMFAChallengeAndVerify';
export * from './useMFAListFactors';
88 changes: 88 additions & 0 deletions packages/ra-supabase-core/src/useMFAChallengeAndVerify.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import {
useMutation,
UseMutationOptions,
UseMutationResult,
} from '@tanstack/react-query';
import { useAuthProvider, useNotify } from 'ra-core';
import {
MFAChallengeAndVerifyParams,
MFAChallengeAndVerifyResult,
SupabaseAuthProvider,
} from './authProvider';

export const useMFAChallengeAndVerify = (
options?: UseMFAChallengeAndVerifyOptions
): [
UseMutationResult<
MFAChallengeAndVerifyResult,
Error,
MFAChallengeAndVerifyParams
>['mutate'],
UseMutationResult<
MFAChallengeAndVerifyResult,
Error,
MFAChallengeAndVerifyParams
>
] => {
const notify = useNotify();
const authProvider = useAuthProvider<SupabaseAuthProvider>();

if (authProvider == null) {
throw new Error(
'No authProvider found. Did you forget to set up an AuthProvider on the <Admin> component?'
);
}

if (authProvider.mfaChallengeAndVerify == null) {
throw new Error(
'The mfaChallengeAndVerify() method is missing from the AuthProvider although it is required. You may consider adding it'
);
}

const {
onSuccess,
onError = error =>
notify(
typeof error === 'string'
? error
: typeof error === 'undefined' || !error.message
? 'ra.auth.sign_in_error'
: error.message,
{
type: 'error',
messageArgs: {
_:
typeof error === 'string'
? error
: error && error.message
? error.message
: undefined,
},
}
),
} = options || {};

const mutation = useMutation<
MFAChallengeAndVerifyResult,
Error,
MFAChallengeAndVerifyParams
>({
mutationFn: params => {
return authProvider.mfaChallengeAndVerify(params);
},
onSuccess,
onError,
retry: false,
});

return [mutation.mutate, mutation];
};

export type UseMFAChallengeAndVerifyOptions = Pick<
UseMutationOptions<
MFAChallengeAndVerifyResult,
Error,
MFAChallengeAndVerifyParams
>,
'onSuccess' | 'onError'
>;
68 changes: 68 additions & 0 deletions packages/ra-supabase-core/src/useMFAEnroll.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import {
useMutation,
UseMutationOptions,
UseMutationResult,
} from '@tanstack/react-query';
import { useAuthProvider, useNotify } from 'ra-core';
import { MFAEnrollResult, SupabaseAuthProvider } from './authProvider';

export const useMFAEnroll = (
options?: UseMFAEnrollOptions
): [
UseMutationResult<MFAEnrollResult, Error, void>['mutate'],
UseMutationResult<MFAEnrollResult, Error, void>
] => {
const notify = useNotify();
const authProvider = useAuthProvider<SupabaseAuthProvider>();

if (authProvider == null) {
throw new Error(
'No authProvider found. Did you forget to set up an AuthProvider on the <Admin> component?'
);
}

if (authProvider.mfaEnroll == null) {
throw new Error(
'The mfaEnroll() method is missing from the AuthProvider although it is required. You may consider adding it'
);
}

const {
onSuccess,
onError = error =>
notify(
typeof error === 'string'
? error
: typeof error === 'undefined' || !error.message
? 'ra.auth.sign_in_error'
: error.message,
{
type: 'error',
messageArgs: {
_:
typeof error === 'string'
? error
: error && error.message
? error.message
: undefined,
},
}
),
} = options || {};

const mutation = useMutation<MFAEnrollResult, Error, void>({
mutationFn: () => {
return authProvider.mfaEnroll({ factorType: 'totp' });
},
onSuccess,
onError,
retry: false,
});

return [mutation.mutate, mutation];
};

export type UseMFAEnrollOptions = Pick<
UseMutationOptions<MFAEnrollResult, Error, void>,
'onSuccess' | 'onError'
>;
Loading
Loading