Skip to content

Commit a7bb469

Browse files
committed
feat(auth): enable otp by clientid and service
Because: - Allow Passwordless OTP to be enabled for a clientId and then only specific services of that clientId or all services. This commit: - Adds a new env var, PASSWORDLESS_ALLOWED_CLIENT_SERVICES, to configure which client and service are allowed to use the passwordless OTP feature. - Removes env var, PASSWORDLESS_ALLOWED_CLIENTIDS env var. - Updates metricsContext to include service Closes #FXA-13178
1 parent 4b56ba0 commit a7bb469

11 files changed

Lines changed: 284 additions & 76 deletions

File tree

.circleci/config.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,7 @@ executors:
177177
# passwordless otp feature
178178
PASSWORDLESS_ENABLED: true
179179
PASSWORDLESS_ALLOWED_SERVICES: '98e6508e88680e1a,5882386c6d801776,dcdb5ae7add825d2'
180+
PASSWORDLESS_ALLOWED_CLIENT_SERVICES: '{"98e6508e88680e1a":{"allowedServices":["*"]},"5882386c6d801776":{"allowedServices":["sync"]},"dcdb5ae7add825d2":{"allowedServices":["*"]}}'
180181
# Seeing if clear customs approach works! RATE_LIMIT__RULES: ""
181182
# RATE_LIMIT__IGNORE_EMAILS: .*@restmail.net$
182183

libs/shared/metrics/glean/src/lib/metrics.context.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ export class MetricsContext {
2929
utmSource?: string;
3030
utmTerm?: string;
3131
clientId?: string;
32+
service?: string;
3233

3334
constructor(queryParams?: Record<string, string | undefined>) {
3435
queryParams = queryParams || {};
@@ -40,6 +41,7 @@ export class MetricsContext {
4041
? Number(queryParams['flowBeginTime'] || queryParams['flow_begin_time'])
4142
: undefined;
4243
this.clientId = queryParams['clientId'] || queryParams['client_id'];
44+
this.service = queryParams['service'];
4345
this.utmCampaign =
4446
queryParams['utmCampaign'] || queryParams['utm_campaign'];
4547
this.utmContent = queryParams['utmContent'] || queryParams['utm_content'];

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

Lines changed: 12 additions & 6 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": ["sync"]
481+
},
482+
"dcdb5ae7add825d2": {
483+
"allowedServices": ["*"]
484+
}
485+
}
480486
}
481-
}
487+
}

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/metrics/context.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ const SCHEMA = isA
4646
productId: isA.string().max(128).optional(),
4747
planId: isA.string().max(128).optional(),
4848
clientId: isA.string().length(16).regex(HEX_STRING).optional(),
49+
service: isA.string().max(128).optional(),
4950
})
5051
.unknown(false)
5152
.and('flowId', 'flowBeginTime');

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

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1639,15 +1639,21 @@ export class AccountHandler {
16391639
// should use their linked provider (Google/Apple), not passwordless OTP.
16401640
const hasLinkedAccount = (account.linkedAccounts?.length || 0) > 0;
16411641
const isExistingPasswordless = account.verifierSetAt === 0 && !hasLinkedAccount;
1642+
1643+
// Get service from metricsContext
1644+
const metricsContext = await request.app.metricsContext;
1645+
const service = metricsContext?.service;
1646+
16421647
result.passwordlessSupported = isExistingPasswordless ||
16431648
(isPasswordlessEligible(
16441649
account,
16451650
email,
16461651
this.config.passwordlessOtp.enabled
16471652
) &&
16481653
isClientAllowedForPasswordless(
1649-
this.config.passwordlessOtp.allowedClientIds as string[],
1650-
clientId
1654+
this.config.passwordlessOtp.allowedClientServices,
1655+
clientId,
1656+
service
16511657
));
16521658
} else {
16531659
const exist = await this.db.accountExists(email);
@@ -1672,17 +1678,23 @@ export class AccountHandler {
16721678
if (thirdPartyAuthStatus) {
16731679
// Passwordless is supported if:
16741680
// 1. Account is eligible (doesn't exist OR enabled globally)
1675-
// 2. AND clientId is allowed
1681+
// 2. AND clientId is allowed for the service
16761682
const isEligible = isPasswordlessEligible(
16771683
null, // null = account doesn't exist
16781684
email,
16791685
this.config.passwordlessOtp.enabled
16801686
);
1687+
1688+
// Get service from metricsContext
1689+
const metricsContext = await request.app.metricsContext;
1690+
const service = metricsContext?.service;
1691+
16811692
result.passwordlessSupported =
16821693
isEligible &&
16831694
isClientAllowedForPasswordless(
1684-
this.config.passwordlessOtp.allowedClientIds as string[],
1685-
clientId
1695+
this.config.passwordlessOtp.allowedClientServices,
1696+
clientId,
1697+
service
16861698
);
16871699
}
16881700
if (this.customs.v2Enabled()) {
@@ -3214,4 +3226,4 @@ export const accountRoutes = (
32143226
}
32153227

32163228
return routes;
3217-
};
3229+
};

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

Lines changed: 22 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -111,23 +111,31 @@ class PasswordlessHandler {
111111
* Existing passwordless accounts bypass the client allowlist and feature flag.
112112
* Third-party auth accounts (with linked accounts) are not passwordless.
113113
*/
114-
private validatePasswordlessAccess(
114+
private async validatePasswordlessAccess(
115115
account: any,
116116
email: string,
117117
clientId: string | undefined,
118+
request: AuthRequest,
118119
logTag: string
119120
) {
120121
const hasLinkedAccount = account && (account.linkedAccounts?.length || 0) > 0;
121122
const isExistingPasswordless = account && account.verifierSetAt === 0 && !hasLinkedAccount;
122-
if (
123-
!isExistingPasswordless &&
124-
!isClientAllowedForPasswordless(
125-
this.config.passwordlessOtp.allowedClientIds as string[],
126-
clientId
127-
)
128-
) {
129-
this.log.error(`passwordless.${logTag}.clientIdNotAllowed`, { clientId });
130-
throw error.featureNotEnabled();
123+
if (!isExistingPasswordless) {
124+
// Get service from metricsContext
125+
const metricsContext = await request.app.metricsContext;
126+
const service = metricsContext?.service;
127+
128+
if (!isClientAllowedForPasswordless(
129+
this.config.passwordlessOtp.allowedClientServices,
130+
clientId,
131+
service
132+
)) {
133+
this.log.error(`passwordless.${logTag}.clientIdOrServiceNotAllowed`, {
134+
clientId,
135+
service
136+
});
137+
throw error.featureNotEnabled();
138+
}
131139
}
132140
if (!isPasswordlessEligible(account, email, this.config.passwordlessOtp.enabled)) {
133141
throw error.cannotCreatePassword();
@@ -145,7 +153,7 @@ class PasswordlessHandler {
145153
await this.customs.check(request, email, 'passwordlessSendOtp');
146154

147155
const { account, isNewAccount } = await this.lookupAccount(email);
148-
this.validatePasswordlessAccess(account, email, clientId, 'sendCode');
156+
await this.validatePasswordlessAccess(account, email, clientId, request, 'sendCode');
149157

150158
return this.generateAndSendOtp(request, email, account, isNewAccount);
151159
}
@@ -167,7 +175,7 @@ class PasswordlessHandler {
167175
const lookupResult = await this.lookupAccount(email);
168176
let account = lookupResult.account;
169177
const { isNewAccount } = lookupResult;
170-
this.validatePasswordlessAccess(account, email, clientId, 'confirmCode');
178+
await this.validatePasswordlessAccess(account, email, clientId, request, 'confirmCode');
171179

172180
// Verify OTP
173181
const otpKey = account ? account.uid : email;
@@ -268,7 +276,7 @@ class PasswordlessHandler {
268276
await this.customs.check(request, email, 'passwordlessSendOtp');
269277

270278
const { account, isNewAccount } = await this.lookupAccount(email);
271-
this.validatePasswordlessAccess(account, email, clientId, 'resendCode');
279+
await this.validatePasswordlessAccess(account, email, clientId, request, 'resendCode');
272280

273281
// Delete existing code before sending a new one
274282
const otpKey = account ? account.uid : email;
@@ -545,4 +553,4 @@ export function passwordlessRoutes(
545553
handler: (request: AuthRequest) => handler.resendCode(request),
546554
},
547555
];
548-
}
556+
}

packages/fxa-auth-server/lib/routes/utils/passwordless.spec.ts

Lines changed: 95 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import {
66
isPasswordlessEligible,
77
isClientAllowedForPasswordless,
8+
AllowedClientServices,
89
} from './passwordless';
910

1011
describe('isPasswordlessEligible', () => {
@@ -67,19 +68,105 @@ describe('isPasswordlessEligible', () => {
6768
});
6869

6970
describe('isClientAllowedForPasswordless', () => {
70-
it('returns true when clientId is in the allowed list', () => {
71-
expect(isClientAllowedForPasswordless(['abc123'], 'abc123')).toBe(true);
71+
const allowedClientServices: AllowedClientServices = {
72+
abc123: { allowedServices: ['sync', 'profile'] },
73+
xyz789: { allowedServices: ['*'] },
74+
empty123: { allowedServices: [] },
75+
};
76+
77+
describe('with valid clientId and service combinations', () => {
78+
it('returns true when clientId and service are both allowed', () => {
79+
expect(
80+
isClientAllowedForPasswordless(allowedClientServices, 'abc123', 'sync')
81+
).toBe(true);
82+
expect(
83+
isClientAllowedForPasswordless(
84+
allowedClientServices,
85+
'abc123',
86+
'profile'
87+
)
88+
).toBe(true);
89+
});
90+
91+
it('returns false when clientId is allowed but service is not', () => {
92+
expect(
93+
isClientAllowedForPasswordless(
94+
allowedClientServices,
95+
'abc123',
96+
'monitor'
97+
)
98+
).toBe(false);
99+
});
100+
101+
it('returns true when clientId is in config and no service specified', () => {
102+
expect(isClientAllowedForPasswordless(allowedClientServices, 'abc123')).toBe(
103+
true
104+
);
105+
});
72106
});
73107

74-
it('returns false when clientId is not in the allowed list', () => {
75-
expect(isClientAllowedForPasswordless(['abc123'], 'xyz789')).toBe(false);
108+
describe('with wildcard support', () => {
109+
it('returns true when allowedServices includes wildcard', () => {
110+
expect(
111+
isClientAllowedForPasswordless(
112+
allowedClientServices,
113+
'xyz789',
114+
'any-service'
115+
)
116+
).toBe(true);
117+
expect(
118+
isClientAllowedForPasswordless(
119+
allowedClientServices,
120+
'xyz789',
121+
'another-service'
122+
)
123+
).toBe(true);
124+
});
76125
});
77126

78-
it('returns false when no clientId is provided', () => {
79-
expect(isClientAllowedForPasswordless(['abc123'])).toBe(false);
127+
describe('with empty allowedServices', () => {
128+
it('returns false when allowedServices is empty array', () => {
129+
expect(
130+
isClientAllowedForPasswordless(
131+
allowedClientServices,
132+
'empty123',
133+
'sync'
134+
)
135+
).toBe(false);
136+
});
137+
138+
it('returns true when allowedServices is empty but no service specified', () => {
139+
expect(
140+
isClientAllowedForPasswordless(allowedClientServices, 'empty123')
141+
).toBe(true);
142+
});
80143
});
81144

82-
it('returns false when allowedClientIds is empty', () => {
83-
expect(isClientAllowedForPasswordless([], 'abc123')).toBe(false);
145+
describe('with invalid inputs', () => {
146+
it('returns false when clientId is not in the config', () => {
147+
expect(
148+
isClientAllowedForPasswordless(
149+
allowedClientServices,
150+
'unknown',
151+
'sync'
152+
)
153+
).toBe(false);
154+
});
155+
156+
it('returns false when no clientId is provided', () => {
157+
expect(
158+
isClientAllowedForPasswordless(allowedClientServices, undefined, 'sync')
159+
).toBe(false);
160+
});
161+
162+
it('returns false when allowedClientServices is empty', () => {
163+
expect(isClientAllowedForPasswordless({}, 'abc123', 'sync')).toBe(false);
164+
});
165+
166+
it('returns false when allowedClientServices is undefined', () => {
167+
expect(
168+
isClientAllowedForPasswordless(undefined as any, 'abc123', 'sync')
169+
).toBe(false);
170+
});
84171
});
85172
});

packages/fxa-auth-server/lib/routes/utils/passwordless.ts

Lines changed: 53 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,61 @@
22
* License, v. 2.0. If a copy of the MPL was not distributed with this
33
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
44

5+
export interface PasswordlessClientConfig {
6+
allowedServices: string[];
7+
}
8+
9+
export type AllowedClientServices = Record<string, PasswordlessClientConfig>;
10+
11+
/**
12+
* Checks if a client is allowed to use passwordless authentication for a specific service.
13+
*
14+
* @param allowedClientServices - Map of clientId to their allowed services configuration
15+
* @param clientId - The OAuth client ID
16+
* @param service - The service being requested (from metricsContext)
17+
* @returns true if the client is allowed for the service, false otherwise
18+
*
19+
* Behavior:
20+
* - If clientId is not in config: deny
21+
* - If clientId is in config but no service specified: allow (client is authorized)
22+
* - If allowedServices is empty array: deny all services
23+
* - If allowedServices includes "*": allow all services
24+
* - Otherwise: check if service is in allowedServices array
25+
*/
526
export function isClientAllowedForPasswordless(
6-
allowedClientIds: string[],
7-
clientId?: string
27+
allowedClientServices: AllowedClientServices,
28+
clientId?: string,
29+
service?: string
830
): boolean {
9-
return !!clientId && allowedClientIds?.includes(clientId);
31+
if (!clientId) {
32+
return false;
33+
}
34+
35+
const clientConfig = allowedClientServices?.[clientId];
36+
37+
if (!clientConfig) {
38+
return false; // Client not in allowlist
39+
}
40+
41+
// If no service specified in request, allow (client is authorized)
42+
if (!service) {
43+
return true;
44+
}
45+
46+
const allowedServices = clientConfig.allowedServices;
47+
48+
// Empty array denies all services
49+
if (!allowedServices || allowedServices.length === 0) {
50+
return false;
51+
}
52+
53+
// Support wildcard for "all services"
54+
if (allowedServices.includes('*')) {
55+
return true;
56+
}
57+
58+
// Check if service is in the allowed list
59+
return allowedServices.includes(service);
1060
}
1161

1262
/**

0 commit comments

Comments
 (0)