Skip to content

Commit c8f6f80

Browse files
authored
Merge pull request #20205 from mozilla/fxa-13178-otp-support-services
feat(auth): enable otp by clientid and service
2 parents 356de34 + 525832a commit c8f6f80

19 files changed

Lines changed: 426 additions & 141 deletions

File tree

.circleci/config.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -176,7 +176,7 @@ executors:
176176
AUTH_CLOUDTASKS_USE_LOCAL_EMULATOR: true
177177
# passwordless otp feature
178178
PASSWORDLESS_ENABLED: true
179-
PASSWORDLESS_ALLOWED_SERVICES: '98e6508e88680e1a,5882386c6d801776,dcdb5ae7add825d2'
179+
PASSWORDLESS_ALLOWED_CLIENT_SERVICES: '{"98e6508e88680e1a":{"allowedServices":["*"]},"5882386c6d801776":{"allowedServices":["relay"]},"dcdb5ae7add825d2":{"allowedServices":["*"]}}'
180180
# Seeing if clear customs approach works! RATE_LIMIT__RULES: ""
181181
# RATE_LIMIT__IGNORE_EMAILS: .*@restmail.net$
182182

packages/functional-tests/tests/passwordless/signinPasswordless.spec.ts

Lines changed: 74 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,10 @@
33
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
44

55
import { expect, test } from '../../lib/fixtures/standard';
6-
import { relayDesktopOAuthQueryParams, syncDesktopOAuthQueryParams } from '../../lib/query-params';
6+
import {
7+
relayDesktopOAuthQueryParams,
8+
syncDesktopOAuthQueryParams,
9+
} from '../../lib/query-params';
710
import { getTotpCode } from '../../lib/totp';
811

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

346349
// Cleanup
347-
await target.authClient.createPassword(
348-
sessionToken,
349-
email,
350-
password
351-
);
350+
await target.authClient.createPassword(sessionToken, email, password);
352351
account.isPasswordless = false;
353352
});
354353
});
@@ -480,10 +479,7 @@ test.describe('severity-1 #smoke', () => {
480479

481480
// Verify TOTP
482481
const totpCode = await getTotpCode(secret);
483-
await target.authClient.verifyTotpCode(
484-
result.sessionToken,
485-
totpCode
486-
);
482+
await target.authClient.verifyTotpCode(result.sessionToken, totpCode);
487483

488484
// Confirm verified after TOTP
489485
const statusAfter = await target.authClient.sessionStatus(
@@ -701,12 +697,11 @@ test.describe('severity-1 #smoke', () => {
701697
});
702698
const cleanupCode =
703699
await target.emailClient.getPasswordlessSigninCode(email);
704-
const cleanupResult =
705-
await target.authClient.passwordlessConfirmCode(
706-
email,
707-
cleanupCode,
708-
{ clientId: 'dcdb5ae7add825d2' }
709-
);
700+
const cleanupResult = await target.authClient.passwordlessConfirmCode(
701+
email,
702+
cleanupCode,
703+
{ clientId: 'dcdb5ae7add825d2' }
704+
);
710705
// Elevate to AAL2 for password creation
711706
const cleanupTotpCode = await getTotpCode(secret);
712707
await target.authClient.verifyTotpCode(
@@ -735,8 +730,7 @@ test.describe('severity-2', () => {
735730
pages: { page, signin, relier, signinPasswordlessCode },
736731
testAccountTracker,
737732
}) => {
738-
const { email } =
739-
testAccountTracker.generatePasswordlessAccountDetails();
733+
const { email } = testAccountTracker.generatePasswordlessAccountDetails();
740734

741735
await relier.goto('force_passwordless=true');
742736
await relier.clickEmailFirst();
@@ -761,8 +755,7 @@ test.describe('severity-2', () => {
761755
pages: { page, signin, relier, signinPasswordlessCode },
762756
testAccountTracker,
763757
}) => {
764-
const { email } =
765-
testAccountTracker.generatePasswordlessAccountDetails();
758+
const { email } = testAccountTracker.generatePasswordlessAccountDetails();
766759

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

783776
// Navigate to /signin directly — same behavior
784-
await page.goto(
785-
`${target.contentServerUrl}/signin?email=${email}`
786-
);
777+
await page.goto(`${target.contentServerUrl}/signin?email=${email}`);
787778
await expect(page).not.toHaveURL(/signin_passwordless_code/);
788779
await expect(signin.cachedSigninHeading).toBeVisible();
789780
});
@@ -793,12 +784,9 @@ test.describe('severity-2', () => {
793784
pages: { page, signin, signinPasswordlessCode, settings },
794785
testAccountTracker,
795786
}) => {
796-
const { email } =
797-
testAccountTracker.generatePasswordlessAccountDetails();
787+
const { email } = testAccountTracker.generatePasswordlessAccountDetails();
798788

799-
await page.goto(
800-
`${target.contentServerUrl}/?force_passwordless=true`
801-
);
789+
await page.goto(`${target.contentServerUrl}/?force_passwordless=true`);
802790
await signin.fillOutEmailFirstForm(email);
803791

804792
await expect(page).toHaveURL(/signin_passwordless_code/);
@@ -886,14 +874,17 @@ test.describe('severity-2', () => {
886874
await target.authClient.passwordlessSendCode(email, {
887875
clientId: 'dcdb5ae7add825d2',
888876
});
889-
const otpCode =
890-
await target.emailClient.getPasswordlessSigninCode(email);
877+
const otpCode = await target.emailClient.getPasswordlessSigninCode(email);
891878
const result = await target.authClient.passwordlessConfirmCode(
892879
email,
893880
otpCode,
894881
{ clientId: 'dcdb5ae7add825d2' }
895882
);
896-
await target.authClient.createPassword(result.sessionToken, email, password);
883+
await target.authClient.createPassword(
884+
result.sessionToken,
885+
email,
886+
password
887+
);
897888
account.isPasswordless = false;
898889

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

921912
test('passwordless signin - Sync with existing passwordless account', async ({
922913
target,
923-
syncOAuthBrowserPages: {
924-
page,
925-
signin,
926-
signinPasswordlessCode,
927-
},
914+
syncOAuthBrowserPages: { page, signin, signinPasswordlessCode },
928915
testAccountTracker,
929916
}) => {
930917
// Create passwordless account via API first (no password)
@@ -1039,7 +1026,7 @@ test.describe('severity-2', () => {
10391026
});
10401027
});
10411028

1042-
test.describe('Passwordless authentication - Browser Service (Relay)', () => {
1029+
test.describe('Passwordless authentication - Browser Service (Relay)', () => {
10431030
test('passwordless signin via Relay OAuth flow', async ({
10441031
target,
10451032
pages: { page, signin, signinPasswordlessCode },
@@ -1062,5 +1049,53 @@ test.describe('Passwordless authentication - Browser Service (Relay)', () => {
10621049
// completes the OAuth flow — verify we left the OTP page
10631050
await expect(page).not.toHaveURL(/signin_passwordless_code/);
10641051
});
1065-
});
1066-
});
1052+
1053+
test('passwordless signup via Relay OAuth flow - service allowed', async ({
1054+
target,
1055+
pages: { page, signin, signinPasswordlessCode },
1056+
testAccountTracker,
1057+
}) => {
1058+
// Test that Relay (which is in allowedClientServices) supports passwordless signup
1059+
const { email } = testAccountTracker.generatePasswordlessAccountDetails();
1060+
1061+
const params = new URLSearchParams(relayDesktopOAuthQueryParams);
1062+
// Add force_passwordless to enable passwordless for new account
1063+
params.set('force_passwordless', 'true');
1064+
await signin.goto('/authorization', params);
1065+
1066+
await signin.fillOutEmailFirstForm(email);
1067+
1068+
// Should redirect to passwordless code page (Relay service is allowed)
1069+
await expect(page).toHaveURL(/signin_passwordless_code/);
1070+
await expect(signinPasswordlessCode.heading).toBeVisible();
1071+
1072+
const code = await target.emailClient.getPasswordlessSignupCode(email);
1073+
await signinPasswordlessCode.fillOutCodeForm(code);
1074+
1075+
// Should complete OAuth flow
1076+
await expect(page).not.toHaveURL(/signin_passwordless_code/);
1077+
});
1078+
1079+
test('passwordless signup blocked for service not in allowedClientServices', async ({
1080+
target,
1081+
pages: { page, signin },
1082+
testAccountTracker,
1083+
}) => {
1084+
// Test that services NOT in allowedClientServices are blocked from passwordless
1085+
const { email } = testAccountTracker.generatePasswordlessAccountDetails();
1086+
1087+
// Use a different OAuth client that is NOT in allowedClientServices
1088+
// (using Sync client as an example of a service that doesn't support passwordless signup)
1089+
const params = new URLSearchParams(syncDesktopOAuthQueryParams);
1090+
params.set('force_passwordless', 'true');
1091+
await signin.goto('/authorization', params);
1092+
1093+
await signin.fillOutEmailFirstForm(email);
1094+
1095+
// Should NOT redirect to passwordless code page
1096+
// Instead should go to traditional signup flow (password form)
1097+
await expect(page).not.toHaveURL(/signin_passwordless_code/);
1098+
await expect(page).toHaveURL(/signup/);
1099+
});
1100+
});
1101+
});

packages/fxa-auth-client/lib/client.ts

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1211,7 +1211,11 @@ export default class AuthClient {
12111211

12121212
async accountStatusByEmail(
12131213
email: string,
1214-
options: { thirdPartyAuthStatus?: boolean; clientId?: string } = {},
1214+
options: {
1215+
thirdPartyAuthStatus?: boolean;
1216+
clientId?: string;
1217+
service?: string;
1218+
} = {},
12151219
headers?: Headers
12161220
) {
12171221
return this.request(
@@ -1227,7 +1231,7 @@ export default class AuthClient {
12271231
*/
12281232
async passwordlessSendCode(
12291233
email: string,
1230-
options: { clientId?: string; metricsContext?: MetricsContext } = {},
1234+
options: { clientId?: string; service?: string; metricsContext?: MetricsContext } = {},
12311235
headers?: Headers
12321236
): Promise<{}> {
12331237
return this.request(
@@ -1244,7 +1248,7 @@ export default class AuthClient {
12441248
async passwordlessConfirmCode(
12451249
email: string,
12461250
code: string,
1247-
options: { clientId?: string; metricsContext?: MetricsContext } = {},
1251+
options: { clientId?: string; service?: string; metricsContext?: MetricsContext } = {},
12481252
headers?: Headers
12491253
): Promise<{
12501254
uid: string;
@@ -1268,7 +1272,7 @@ export default class AuthClient {
12681272
*/
12691273
async passwordlessResendCode(
12701274
email: string,
1271-
options: { clientId?: string; metricsContext?: MetricsContext } = {},
1275+
options: { clientId?: string; service?: string; metricsContext?: MetricsContext } = {},
12721276
headers?: Headers
12731277
): Promise<{}> {
12741278
return this.request(
@@ -3440,4 +3444,4 @@ export default class AuthClient {
34403444
throw error;
34413445
}
34423446
}
3443-
}
3447+
}

packages/fxa-auth-server/config/dev.json

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -472,10 +472,16 @@
472472
},
473473
"passwordlessOtp": {
474474
"enabled": true,
475-
"allowedClientIds": [
476-
"98e6508e88680e1a",
477-
"5882386c6d801776",
478-
"dcdb5ae7add825d2"
479-
]
475+
"allowedClientServices": {
476+
"98e6508e88680e1a": {
477+
"allowedServices": ["*"]
478+
},
479+
"5882386c6d801776": {
480+
"allowedServices": ["relay"]
481+
},
482+
"dcdb5ae7add825d2": {
483+
"allowedServices": ["*"]
484+
}
485+
}
480486
}
481487
}

packages/fxa-auth-server/config/index.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2173,11 +2173,11 @@ const convictConf = convict({
21732173
format: Boolean,
21742174
env: 'PASSWORDLESS_ENABLED',
21752175
},
2176-
allowedClientIds: {
2177-
doc: 'Array of clients ids allowed to use passwordless authentication. Empty array means no service is allowed.',
2178-
format: Array,
2179-
default: [],
2180-
env: 'PASSWORDLESS_ALLOWED_SERVICES',
2176+
allowedClientServices: {
2177+
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.',
2178+
format: Object,
2179+
default: {},
2180+
env: 'PASSWORDLESS_ALLOWED_CLIENT_SERVICES',
21812181
},
21822182
digits: {
21832183
doc: 'Number of digits in passwordless OTP code',
@@ -2962,3 +2962,4 @@ export type ConfigType = ReturnType<conf['getProperties']>;
29622962

29632963
export { convictConf as config };
29642964
export default convictConf;
2965+

packages/fxa-auth-server/lib/routes/account.spec.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1679,7 +1679,9 @@ describe('/account/status', () => {
16791679
extraConfig: {
16801680
passwordlessOtp: {
16811681
enabled: true,
1682-
allowedClientIds: ['test-client-id'],
1682+
allowedClientServices: {
1683+
'test-client-id': { allowedServices: ['*'] },
1684+
},
16831685
},
16841686
},
16851687
shouldError: true,

packages/fxa-auth-server/lib/routes/account.ts

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1606,6 +1606,7 @@ export class AccountHandler {
16061606
const thirdPartyAuthStatus = !!(request.payload as any)
16071607
.thirdPartyAuthStatus;
16081608
const clientId = (request.payload as any).clientId;
1609+
const service = (request.payload as any).service;
16091610
let invalidDomain = false;
16101611

16111612
if (checkDomain) {
@@ -1666,8 +1667,9 @@ export class AccountHandler {
16661667
this.config.passwordlessOtp.enabled
16671668
) &&
16681669
isClientAllowedForPasswordless(
1669-
this.config.passwordlessOtp.allowedClientIds as string[],
1670-
clientId
1670+
this.config.passwordlessOtp.allowedClientServices,
1671+
clientId,
1672+
service
16711673
));
16721674
} else {
16731675
const exist = await this.db.accountExists(email);
@@ -1690,19 +1692,18 @@ export class AccountHandler {
16901692
}
16911693
// For non-existent accounts, check if passwordless is supported
16921694
if (thirdPartyAuthStatus) {
1693-
// Passwordless is supported if:
1694-
// 1. Account is eligible (doesn't exist OR enabled globally)
1695-
// 2. AND clientId is allowed
16961695
const isEligible = isPasswordlessEligible(
16971696
null, // null = account doesn't exist
16981697
email,
16991698
this.config.passwordlessOtp.enabled
17001699
);
1700+
17011701
result.passwordlessSupported =
17021702
isEligible &&
17031703
isClientAllowedForPasswordless(
1704-
this.config.passwordlessOtp.allowedClientIds as string[],
1705-
clientId
1704+
this.config.passwordlessOtp.allowedClientServices,
1705+
clientId,
1706+
service
17061707
);
17071708
}
17081709
if (this.customs.v2Enabled()) {
@@ -2887,6 +2888,7 @@ export const accountRoutes = (
28872888
thirdPartyAuthStatus: isA.boolean().optional().default(false),
28882889
checkDomain: isA.optional(),
28892890
clientId: isA.string().optional(),
2891+
service: validators.service.optional(),
28902892
}),
28912893
},
28922894
response: {

0 commit comments

Comments
 (0)