Skip to content
Merged
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
2 changes: 1 addition & 1 deletion src/application/services/domains/auth.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
export { signInWithUrl, verifyToken, getServerInfo, getAuthProviders } from '../js-services/http/auth-api';
export { getUserAuthInfo, type GoTrueUserAuthInfo } from '../js-services/http/gotrue';
export { getUserAuthInfo, resetPassword, type GoTrueUserAuthInfo } from '../js-services/http/gotrue';
export {
loginAuth as login,
signInGoogleWithRedirect as signInGoogle,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ const mockGrantClient = {
},
get: jest.fn(),
post: jest.fn(),
put: jest.fn(),
};

const mockAxiosCreate = jest.fn(() => mockGrantClient);
Expand All @@ -28,14 +29,17 @@ jest.mock('@/utils/log', () => ({
Log: { debug: jest.fn(), error: jest.fn(), info: jest.fn(), warn: jest.fn() },
}));

const { changePassword, getUserAuthInfo, initGrantService } = require('../gotrue') as typeof import('../gotrue');
const { changePassword, getUserAuthInfo, initGrantService, resetPassword } = require('../gotrue') as typeof import('../gotrue');
const { invalidToken } = require('@/application/session/token') as { invalidToken: jest.Mock };

const authHeaders = expect.objectContaining({ headers: expect.objectContaining({ Authorization: 'Bearer access-token' }) });

describe('GoTrue password requests', () => {
beforeEach(() => {
mockGrantClient.get.mockReset();
mockGrantClient.post.mockReset().mockResolvedValue({ data: {} });
mockGrantClient.put.mockReset().mockResolvedValue({ data: {} });
invalidToken.mockClear();
initGrantService('http://localhost/gotrue');
});

Expand Down Expand Up @@ -77,4 +81,24 @@ describe('GoTrue password requests', () => {

await expect(getUserAuthInfo()).rejects.toMatchObject({ sourceDomain: 'gotrue', httpStatus: 404 });
});

it('resets a forgotten password through the user update without the current password', async () => {
await resetPassword({ password: 'Reset-password123!' });

expect(mockGrantClient.put).toHaveBeenCalledWith('/user', { password: 'Reset-password123!' }, authHeaders);
expect(mockGrantClient.post).not.toHaveBeenCalled();
});

it.each([
[503, 0],
[401, 1],
])('keeps the session unless the reset is unauthorized (HTTP %i)', async (status, invalidations) => {
mockGrantClient.put.mockRejectedValueOnce({ message: 'Failed', response: { status, data: { msg: 'Failed' } } });

await expect(resetPassword({ password: 'Reset-password123!' })).rejects.toMatchObject({
sourceDomain: 'gotrue',
httpStatus: status,
});
expect(invalidToken).toHaveBeenCalledTimes(invalidations);
});
});
41 changes: 41 additions & 0 deletions src/application/services/js-services/http/gotrue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -359,6 +359,47 @@ export async function getUserAuthInfo(): Promise<GoTrueUserAuthInfo> {
}
}

// Sets a new password from a password-recovery session. Unlike change-password,
// GoTrue's user update does not require the current password for sessions
// opened with an emailed code, which is all a user who forgot it can provide.
export async function resetPassword(params: { password: string }) {
Log.info('[Auth] resetPassword: starting');
const access_token = getTokenParsed()?.access_token;

if (!access_token) {
Log.warn('[Auth] resetPassword: no access token found');
return Promise.reject({
code: -1,
message: 'You have not logged in yet. Can not reset password.',
});
}

try {
await axiosInstance?.put(
'/user',
{ password: params.password },
{
headers: {
Authorization: `Bearer ${access_token}`,
},
}
);

Log.info('[Auth] resetPassword: success');
// eslint-disable-next-line
} catch (e: any) {
Log.error('[Auth] resetPassword: failed', {
status: e.response?.status,
message: e.response?.data?.msg || e.message,
});
if (e.response?.status === 401) {
invalidToken();
}

return Promise.reject(parseGoTrueFailure(e));
}
}

export async function changePassword(params: { password: string; currentPassword?: string }) {
Log.info('[Auth] changePassword: starting');
try {
Expand Down
23 changes: 23 additions & 0 deletions src/application/session/__tests__/sign_in.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -471,6 +471,29 @@ describe('afterAuth', () => {
expect(localStorage.getItem('redirectTo')).toBeNull();
});

it.each([
'/login?action=changePassword&email=user%40example.com',
'http://localhost/login/?action=changePassword&email=user%40example.com&redirectTo=%2Fapp',
])('lets a password-recovery session reach the new-password form: %s', (redirectTo) => {
localStorage.setItem('redirectTo', redirectTo);

afterAuth();

expect(window.location.href).toBe(redirectTo);
expect(localStorage.getItem('redirectTo')).toBeNull();
});

it.each(['/auth/callback?action=changePassword', '/login?action=checkEmailResetPassword'])(
'keeps other authentication routes out of reach: %s',
(redirectTo) => {
localStorage.setItem('redirectTo', redirectTo);

afterAuth();

expect(window.location.href).toBe('/app');
}
);

it.each(['/login/foo', '/loginish', '/auth/callback-other'])(
'preserves a safe non-authentication destination: %s',
(redirectTo) => {
Expand Down
23 changes: 19 additions & 4 deletions src/application/session/sign_in.ts
Original file line number Diff line number Diff line change
Expand Up @@ -248,7 +248,10 @@ export function getAuthCallbackUrl(redirectTo: string): string {
return callbackUrl.toString();
}

export function isAuthPath(pathname: string): boolean {
// Login-page action that shows the new-password form after a recovery code.
export const PASSWORD_RESET_LOGIN_ACTION = 'changePassword';

function normalizeRoutePathname(pathname: string): string | null {
let decodedPathname: string;

try {
Expand All @@ -259,14 +262,26 @@ export function isAuthPath(pathname: string): boolean {
.map((segment) => decodeURIComponent(segment).replace(/\//g, '%2F'))
.join('/');
} catch {
return false;
return null;
}

const normalizedPathname = decodedPathname.replace(/\/+$/, '').toLowerCase();
return decodedPathname.replace(/\/+$/, '').toLowerCase();
}

export function isAuthPath(pathname: string): boolean {
const normalizedPathname = normalizeRoutePathname(pathname);

return normalizedPathname === '/login' || normalizedPathname === AUTH_CALLBACK_PATH;
}

// A recovery session must still reach the new-password form on the login page.
function isPasswordResetForm(url: URL): boolean {
return (
normalizeRoutePathname(url.pathname) === '/login' &&
url.searchParams.get('action') === PASSWORD_RESET_LOGIN_ACTION
);
}

const MAX_REDIRECT_VALIDATION_DECODES = 5;
const ABSOLUTE_URL_PATTERN = /^[a-z][a-z\d+.-]*:/i;
const AUTHORITY_RELATIVE_URL_PATTERN = /^[\\/]{2}/;
Expand Down Expand Up @@ -415,7 +430,7 @@ export function afterAuth() {
// Pattern matches /app/{uuid}/{uuid} or /app/{uuid}
const hasUserSpecificIds = /\/app\/[a-f0-9-]{36}/i.test(pathname);

if (isAuthPath(pathname)) {
if (isAuthPath(pathname) && !isPasswordResetForm(url)) {
// Authentication pages are transitional destinations. Sending a newly
// authenticated user back to one can strand them on the login screen and
// require a second sign-in attempt.
Expand Down
2 changes: 1 addition & 1 deletion src/components/login/ChangePassword.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ export function ChangePassword({ email, redirectTo }: { email: string; redirectT
setPasswordErrors([]);

try {
await AuthService.changePassword({ password: newPassword });
await AuthService.resetPassword({ password: newPassword });
toast.success(t('changePassword.success'));

invalidToken();
Expand Down
37 changes: 37 additions & 0 deletions src/components/login/__tests__/ChangePassword.reset.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import { toast } from 'sonner';

import { AuthService } from '@/application/services/domains';
import { invalidToken } from '@/application/session/token';
import { ChangePassword } from '@/components/login/ChangePassword';

jest.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => key }), Trans: () => null }));
jest.mock('@/application/services/domains', () => ({
AuthService: { changePassword: jest.fn(), resetPassword: jest.fn() },
}));
jest.mock('@/application/session/token', () => ({ invalidToken: jest.fn() }));
jest.mock('@/components/main/app.hooks', () => ({ useIsAuthenticatedOptional: () => true }));
jest.mock('sonner', () => ({ toast: { error: jest.fn(), success: jest.fn() } }));

it('resets a forgotten password without asking for the current one', async () => {
jest.mocked(AuthService.resetPassword).mockResolvedValueOnce(undefined);
render(
<MemoryRouter future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
<ChangePassword email='user@example.com' redirectTo='/app' />
</MemoryRouter>
);

fireEvent.change(screen.getByPlaceholderText('changePassword.placeholder'), {
target: { value: 'Reset-password123!' },
});
fireEvent.change(screen.getByPlaceholderText('changePassword.confirmPassword'), {
target: { value: 'Reset-password123!' },
});
fireEvent.click(screen.getByRole('button', { name: 'changePassword.submit' }));

await waitFor(() => expect(invalidToken).toHaveBeenCalledTimes(1));
expect(AuthService.resetPassword).toHaveBeenCalledWith({ password: 'Reset-password123!' });
expect(AuthService.changePassword).not.toHaveBeenCalled();
expect(toast.success).toHaveBeenCalledWith('changePassword.success');
});
4 changes: 2 additions & 2 deletions src/components/login/__tests__/SignUpPassword.errors.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ jest.mock('react-i18next', () => ({
useTranslation: () => ({ t: (key: string) => key === 'changePassword.passwordErrorMatch' ? 'Passwords differ' : key }),
Trans: () => null,
}));
jest.mock('@/application/services/domains', () => ({ AuthService: { signUpWithPassword: jest.fn(), changePassword: jest.fn() } }));
jest.mock('@/application/services/domains', () => ({ AuthService: { signUpWithPassword: jest.fn(), resetPassword: jest.fn() } }));
jest.mock('@/components/main/app.hooks', () => ({ useIsAuthenticatedOptional: () => true }));
jest.mock('sonner', () => ({ toast: { error: jest.fn(), success: jest.fn() } }));

Expand Down Expand Up @@ -42,7 +42,7 @@ it.each(['signup', 'change'])('keeps server wording separate from %s password-co
user_error: { schema_version: 1, reason: 'AUTH_REQUEST_PROBLEM', message: publicMessage, locale: 'en' } });

jest.mocked(AuthService.signUpWithPassword).mockRejectedValueOnce(error);
jest.mocked(AuthService.changePassword).mockRejectedValueOnce(error);
jest.mocked(AuthService.resetPassword).mockRejectedValueOnce(error);
render(<MemoryRouter future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
{form === 'signup' ? <SignUpPassword redirectTo='/app' /> : <ChangePassword email='user@example.com' redirectTo='/app' />}
</MemoryRouter>);
Expand Down
4 changes: 3 additions & 1 deletion src/components/login/const.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { PASSWORD_RESET_LOGIN_ACTION } from '@/application/session/sign_in';

export const LOGIN_ACTION = {
CHECK_EMAIL: 'checkEmail',
ENTER_PASSWORD: 'enterPassword',
RESET_PASSWORD: 'resetPassword',
CHECK_EMAIL_RESET_PASSWORD: 'checkEmailResetPassword',
CHANGE_PASSWORD: 'changePassword',
CHANGE_PASSWORD: PASSWORD_RESET_LOGIN_ACTION,
SIGN_UP_PASSWORD: 'signUpPassword',
} as const;
Loading