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 .circleci/config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,7 @@ executors:
AUTH_CLOUDTASKS_USE_LOCAL_EMULATOR: true
# passwordless otp feature
PASSWORDLESS_ENABLED: true
PASSWORDLESS_ALLOWED_SERVICES: '98e6508e88680e1a,5882386c6d801776,dcdb5ae7add825d2'
PASSWORDLESS_ALLOWED_CLIENT_SERVICES: '{"98e6508e88680e1a":{"allowedServices":["*"]},"5882386c6d801776":{"allowedServices":["relay"]},"dcdb5ae7add825d2":{"allowedServices":["*"]}}'
# Seeing if clear customs approach works! RATE_LIMIT__RULES: ""
# RATE_LIMIT__IGNORE_EMAILS: .*@restmail.net$

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,10 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */

import { expect, test } from '../../lib/fixtures/standard';
import { relayDesktopOAuthQueryParams, syncDesktopOAuthQueryParams } from '../../lib/query-params';
import {
relayDesktopOAuthQueryParams,
syncDesktopOAuthQueryParams,
} from '../../lib/query-params';
import { getTotpCode } from '../../lib/totp';

test.describe('severity-1 #smoke', () => {
Expand Down Expand Up @@ -344,11 +347,7 @@ test.describe('severity-1 #smoke', () => {
expect(Array.isArray(events)).toBe(true);

// Cleanup
await target.authClient.createPassword(
sessionToken,
email,
password
);
await target.authClient.createPassword(sessionToken, email, password);
account.isPasswordless = false;
});
});
Expand Down Expand Up @@ -480,10 +479,7 @@ test.describe('severity-1 #smoke', () => {

// Verify TOTP
const totpCode = await getTotpCode(secret);
await target.authClient.verifyTotpCode(
result.sessionToken,
totpCode
);
await target.authClient.verifyTotpCode(result.sessionToken, totpCode);

// Confirm verified after TOTP
const statusAfter = await target.authClient.sessionStatus(
Expand Down Expand Up @@ -701,12 +697,11 @@ test.describe('severity-1 #smoke', () => {
});
const cleanupCode =
await target.emailClient.getPasswordlessSigninCode(email);
const cleanupResult =
await target.authClient.passwordlessConfirmCode(
email,
cleanupCode,
{ clientId: 'dcdb5ae7add825d2' }
);
const cleanupResult = await target.authClient.passwordlessConfirmCode(
email,
cleanupCode,
{ clientId: 'dcdb5ae7add825d2' }
);
// Elevate to AAL2 for password creation
const cleanupTotpCode = await getTotpCode(secret);
await target.authClient.verifyTotpCode(
Expand Down Expand Up @@ -735,8 +730,7 @@ test.describe('severity-2', () => {
pages: { page, signin, relier, signinPasswordlessCode },
testAccountTracker,
}) => {
const { email } =
testAccountTracker.generatePasswordlessAccountDetails();
const { email } = testAccountTracker.generatePasswordlessAccountDetails();

await relier.goto('force_passwordless=true');
await relier.clickEmailFirst();
Expand All @@ -761,8 +755,7 @@ test.describe('severity-2', () => {
pages: { page, signin, relier, signinPasswordlessCode },
testAccountTracker,
}) => {
const { email } =
testAccountTracker.generatePasswordlessAccountDetails();
const { email } = testAccountTracker.generatePasswordlessAccountDetails();

await relier.goto('force_passwordless=true');
await relier.clickEmailFirst();
Expand All @@ -781,9 +774,7 @@ test.describe('severity-2', () => {
await expect(signin.cachedSigninHeading).toBeVisible();

// Navigate to /signin directly — same behavior
await page.goto(
`${target.contentServerUrl}/signin?email=${email}`
);
await page.goto(`${target.contentServerUrl}/signin?email=${email}`);
await expect(page).not.toHaveURL(/signin_passwordless_code/);
await expect(signin.cachedSigninHeading).toBeVisible();
});
Expand All @@ -793,12 +784,9 @@ test.describe('severity-2', () => {
pages: { page, signin, signinPasswordlessCode, settings },
testAccountTracker,
}) => {
const { email } =
testAccountTracker.generatePasswordlessAccountDetails();
const { email } = testAccountTracker.generatePasswordlessAccountDetails();

await page.goto(
`${target.contentServerUrl}/?force_passwordless=true`
);
await page.goto(`${target.contentServerUrl}/?force_passwordless=true`);
await signin.fillOutEmailFirstForm(email);

await expect(page).toHaveURL(/signin_passwordless_code/);
Expand Down Expand Up @@ -886,14 +874,17 @@ test.describe('severity-2', () => {
await target.authClient.passwordlessSendCode(email, {
clientId: 'dcdb5ae7add825d2',
});
const otpCode =
await target.emailClient.getPasswordlessSigninCode(email);
const otpCode = await target.emailClient.getPasswordlessSigninCode(email);
const result = await target.authClient.passwordlessConfirmCode(
email,
otpCode,
{ clientId: 'dcdb5ae7add825d2' }
);
await target.authClient.createPassword(result.sessionToken, email, password);
await target.authClient.createPassword(
result.sessionToken,
email,
password
);
account.isPasswordless = false;

// First account now has a password — should show password form
Expand All @@ -920,11 +911,7 @@ test.describe('severity-2', () => {

test('passwordless signin - Sync with existing passwordless account', async ({
target,
syncOAuthBrowserPages: {
page,
signin,
signinPasswordlessCode,
},
syncOAuthBrowserPages: { page, signin, signinPasswordlessCode },
testAccountTracker,
}) => {
// Create passwordless account via API first (no password)
Expand Down Expand Up @@ -1039,7 +1026,7 @@ test.describe('severity-2', () => {
});
});

test.describe('Passwordless authentication - Browser Service (Relay)', () => {
test.describe('Passwordless authentication - Browser Service (Relay)', () => {
test('passwordless signin via Relay OAuth flow', async ({
target,
pages: { page, signin, signinPasswordlessCode },
Expand All @@ -1062,5 +1049,53 @@ test.describe('Passwordless authentication - Browser Service (Relay)', () => {
// completes the OAuth flow — verify we left the OTP page
await expect(page).not.toHaveURL(/signin_passwordless_code/);
});
});
});

test('passwordless signup via Relay OAuth flow - service allowed', async ({
target,
pages: { page, signin, signinPasswordlessCode },
testAccountTracker,
}) => {
// Test that Relay (which is in allowedClientServices) supports passwordless signup
const { email } = testAccountTracker.generatePasswordlessAccountDetails();

const params = new URLSearchParams(relayDesktopOAuthQueryParams);
// Add force_passwordless to enable passwordless for new account
params.set('force_passwordless', 'true');
await signin.goto('/authorization', params);

await signin.fillOutEmailFirstForm(email);

// Should redirect to passwordless code page (Relay service is allowed)
await expect(page).toHaveURL(/signin_passwordless_code/);
await expect(signinPasswordlessCode.heading).toBeVisible();

const code = await target.emailClient.getPasswordlessSignupCode(email);
await signinPasswordlessCode.fillOutCodeForm(code);

// Should complete OAuth flow
await expect(page).not.toHaveURL(/signin_passwordless_code/);
});

test('passwordless signup blocked for service not in allowedClientServices', async ({
target,
pages: { page, signin },
testAccountTracker,
}) => {
// Test that services NOT in allowedClientServices are blocked from passwordless
const { email } = testAccountTracker.generatePasswordlessAccountDetails();

// Use a different OAuth client that is NOT in allowedClientServices
// (using Sync client as an example of a service that doesn't support passwordless signup)
const params = new URLSearchParams(syncDesktopOAuthQueryParams);
params.set('force_passwordless', 'true');
await signin.goto('/authorization', params);

await signin.fillOutEmailFirstForm(email);

// Should NOT redirect to passwordless code page
// Instead should go to traditional signup flow (password form)
await expect(page).not.toHaveURL(/signin_passwordless_code/);
await expect(page).toHaveURL(/signup/);
});
});
});
14 changes: 9 additions & 5 deletions packages/fxa-auth-client/lib/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1211,7 +1211,11 @@ export default class AuthClient {

async accountStatusByEmail(
email: string,
options: { thirdPartyAuthStatus?: boolean; clientId?: string } = {},
options: {
thirdPartyAuthStatus?: boolean;
clientId?: string;
service?: string;
} = {},
headers?: Headers
) {
return this.request(
Expand All @@ -1227,7 +1231,7 @@ export default class AuthClient {
*/
async passwordlessSendCode(
email: string,
options: { clientId?: string; metricsContext?: MetricsContext } = {},
options: { clientId?: string; service?: string; metricsContext?: MetricsContext } = {},
headers?: Headers
): Promise<{}> {
return this.request(
Expand All @@ -1244,7 +1248,7 @@ export default class AuthClient {
async passwordlessConfirmCode(
email: string,
code: string,
options: { clientId?: string; metricsContext?: MetricsContext } = {},
options: { clientId?: string; service?: string; metricsContext?: MetricsContext } = {},
headers?: Headers
): Promise<{
uid: string;
Expand All @@ -1268,7 +1272,7 @@ export default class AuthClient {
*/
async passwordlessResendCode(
email: string,
options: { clientId?: string; metricsContext?: MetricsContext } = {},
options: { clientId?: string; service?: string; metricsContext?: MetricsContext } = {},
headers?: Headers
): Promise<{}> {
return this.request(
Expand Down Expand Up @@ -3440,4 +3444,4 @@ export default class AuthClient {
throw error;
}
}
}
}
16 changes: 11 additions & 5 deletions packages/fxa-auth-server/config/dev.json
Original file line number Diff line number Diff line change
Expand Up @@ -472,10 +472,16 @@
},
"passwordlessOtp": {
"enabled": true,
"allowedClientIds": [
"98e6508e88680e1a",
"5882386c6d801776",
"dcdb5ae7add825d2"
]
"allowedClientServices": {
"98e6508e88680e1a": {
"allowedServices": ["*"]
},
"5882386c6d801776": {
"allowedServices": ["relay"]
},
"dcdb5ae7add825d2": {
"allowedServices": ["*"]
}
}
}
}
11 changes: 6 additions & 5 deletions packages/fxa-auth-server/config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2173,11 +2173,11 @@ const convictConf = convict({
format: Boolean,
env: 'PASSWORDLESS_ENABLED',
},
allowedClientIds: {
doc: 'Array of clients ids allowed to use passwordless authentication. Empty array means no service is allowed.',
format: Array,
default: [],
env: 'PASSWORDLESS_ALLOWED_SERVICES',
allowedClientServices: {
doc: 'Map of client IDs to their allowed services for passwordless authentication. Format: {"clientId": {"allowedServices": ["service1", "service2"]}}. Use "*" in allowedServices for all services. Empty array denies all services.',
format: Object,
default: {},
env: 'PASSWORDLESS_ALLOWED_CLIENT_SERVICES',
},
digits: {
doc: 'Number of digits in passwordless OTP code',
Expand Down Expand Up @@ -2962,3 +2962,4 @@ export type ConfigType = ReturnType<conf['getProperties']>;

export { convictConf as config };
export default convictConf;

4 changes: 3 additions & 1 deletion packages/fxa-auth-server/lib/routes/account.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1679,7 +1679,9 @@ describe('/account/status', () => {
extraConfig: {
passwordlessOtp: {
enabled: true,
allowedClientIds: ['test-client-id'],
allowedClientServices: {
'test-client-id': { allowedServices: ['*'] },
},
},
},
shouldError: true,
Expand Down
16 changes: 9 additions & 7 deletions packages/fxa-auth-server/lib/routes/account.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1606,6 +1606,7 @@ export class AccountHandler {
const thirdPartyAuthStatus = !!(request.payload as any)
.thirdPartyAuthStatus;
const clientId = (request.payload as any).clientId;
const service = (request.payload as any).service;
let invalidDomain = false;

if (checkDomain) {
Expand Down Expand Up @@ -1666,8 +1667,9 @@ export class AccountHandler {
this.config.passwordlessOtp.enabled
) &&
isClientAllowedForPasswordless(
this.config.passwordlessOtp.allowedClientIds as string[],
clientId
this.config.passwordlessOtp.allowedClientServices,
clientId,
service
));
} else {
const exist = await this.db.accountExists(email);
Expand All @@ -1690,19 +1692,18 @@ export class AccountHandler {
}
// For non-existent accounts, check if passwordless is supported
if (thirdPartyAuthStatus) {
// Passwordless is supported if:
// 1. Account is eligible (doesn't exist OR enabled globally)
// 2. AND clientId is allowed
const isEligible = isPasswordlessEligible(
null, // null = account doesn't exist
email,
this.config.passwordlessOtp.enabled
);

result.passwordlessSupported =
isEligible &&
isClientAllowedForPasswordless(
this.config.passwordlessOtp.allowedClientIds as string[],
clientId
this.config.passwordlessOtp.allowedClientServices,
clientId,
service
);
}
if (this.customs.v2Enabled()) {
Expand Down Expand Up @@ -2887,6 +2888,7 @@ export const accountRoutes = (
thirdPartyAuthStatus: isA.boolean().optional().default(false),
checkDomain: isA.optional(),
clientId: isA.string().optional(),
service: validators.service.optional(),
}),
},
response: {
Expand Down
Loading
Loading