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
26 changes: 26 additions & 0 deletions packages/fxa-auth-server/config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2161,6 +2161,32 @@ const convictConf = convict({
env: 'OTP_SIGNUP_DIGIT',
},
},
passwordlessOtp: {
enabled: {
doc: 'Enable passwordless authentication feature',
default: false,
format: Boolean,
env: 'PASSWORDLESS_ENABLED',
},
forcedEmailAddresses: {
doc: 'Force passwordless flow for email addresses matching this regex (for testing)',
format: RegExp,
default: /^passwordless.*@restmail\.net$/,
env: 'PASSWORDLESS_FORCED_EMAIL_REGEX',
},
digits: {
doc: 'Number of digits in passwordless OTP code',
default: 8,
format: 'nat',
env: 'OTP_PASSWORDLESS_DIGITS',
},
ttl: {
doc: 'Duration in seconds when the passwordless OTP is valid',
default: 10 * 60,
format: 'nat',
env: 'OTP_PASSWORDLESS_TTL',
},
},
accountDestroy: {
requireVerifiedAccount: {
doc: 'Whether or not the account must be verified in order to destroy it.',
Expand Down
16 changes: 16 additions & 0 deletions packages/fxa-auth-server/config/rate-limit-rules.txt
Original file line number Diff line number Diff line change
Expand Up @@ -156,3 +156,19 @@ mfaOtpCodeVerifyForEmail : uid : 5 : 5 minu
mfaOtpCodeVerifyFor2fa : uid : 5 : 5 minutes : 15 minutes : block
mfaOtpCodeVerifyForPassword : uid : 5 : 5 minutes : 15 minutes : block
mfaOtpCodeVerifyForRecoveryKey : uid : 5 : 5 minutes : 15 minutes : block

#
# Passwordless Authentication OTP Limits
# Controls the rate at which passwordless OTP codes can be sent and verified
#
passwordlessSendOtp : email : 2 : 15 minutes : 15 minutes : block
passwordlessSendOtp : email : 5 : 24 hours : 12 hours : block
passwordlessSendOtp : ip : 50 : 24 hours : 12 hours : block
passwordlessSendOtp : ip : 20 : 15 minutes : 30 minutes : block
passwordlessSendOtp : ip : 100 : 24 hours : 15 minutes : ban

# Passwordless OTP Verification Limits
passwordlessVerifyOtp : ip_email : 5 : 10 minutes : 15 minutes : block
passwordlessVerifyOtp : ip : 100 : 24 hours : 15 minutes : ban
passwordlessVerifyOtpPerDay : ip_email : 10 : 24 hours : 24 hours : block
passwordlessVerifyOtpPerDay : ip : 100 : 24 hours : 15 minutes : ban
107 changes: 107 additions & 0 deletions packages/fxa-auth-server/docs/swagger/passwordless-api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */

import dedent from 'dedent';
import TAGS from './swagger-tags';

const TAGS_PASSWORDLESS = {
tags: TAGS.PASSWORDLESS,
};

const PASSWORDLESS_SEND_CODE_POST = {
...TAGS_PASSWORDLESS,
description: '/account/passwordless/send_code',
notes: [
dedent`
Send a one-time password (OTP) code to the user's email for passwordless authentication.

This endpoint can be used for both:
- New user registration (account doesn't exist)
- Login for existing passwordless accounts (accounts without a password)

Accounts with passwords set cannot use this endpoint.
`,
],
plugins: {
'hapi-swagger': {
responses: {
400: {
description: dedent`
Failing requests may be caused by the following errors:
- \`errno: 148\` - Account has a password set, use standard login flow
`,
},
429: {
description: 'Rate limit exceeded',
},
},
},
},
};

const PASSWORDLESS_CONFIRM_CODE_POST = {
...TAGS_PASSWORDLESS,
description: '/account/passwordless/confirm_code',
notes: [
dedent`
Confirm the OTP code sent via \`/account/passwordless/send_code\`.

On success:
- For new users: Creates a new account and returns a session token
- For existing users: Returns a session token for the existing account

The \`isNewAccount\` field in the response indicates whether a new account was created.
`,
],
plugins: {
'hapi-swagger': {
responses: {
400: {
description: dedent`
Failing requests may be caused by the following errors:
- \`errno: 183\` - Invalid OTP code
- \`errno: 148\` - Account has a password set
`,
},
429: {
description: 'Rate limit exceeded',
},
},
},
},
};

const PASSWORDLESS_RESEND_CODE_POST = {
...TAGS_PASSWORDLESS,
description: '/account/passwordless/resend_code',
notes: [
dedent`
Resend the OTP code for passwordless authentication.

This invalidates any previously sent code and sends a new one.
Subject to the same rate limits as \`/account/passwordless/send_code\`.
`,
],
plugins: {
'hapi-swagger': {
responses: {
400: {
description: dedent`
Failing requests may be caused by the following errors:
- \`errno: 148\` - Account has a password set
`,
},
429: {
description: 'Rate limit exceeded',
},
},
},
},
};

export default {
PASSWORDLESS_SEND_CODE_POST,
PASSWORDLESS_CONFIRM_CODE_POST,
PASSWORDLESS_RESEND_CODE_POST,
};
1 change: 1 addition & 0 deletions packages/fxa-auth-server/docs/swagger/swagger-tags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ const TAGS = {
OAUTH: ['api', 'Oauth'],
OAUTH_SERVER: ['api', 'OAuth Server API Overview'],
PASSWORD: ['api', 'Password'],
PASSWORDLESS: ['api', 'Passwordless'],
RECOVERY_PHONE: ['api', 'Recovery phone'],
RECOVERY_CODES: ['api', 'Backup authentication codes'],
RECOVERY_KEY: ['api', 'Account recovery key'],
Expand Down
119 changes: 76 additions & 43 deletions packages/fxa-auth-server/lib/routes/account.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1607,11 +1607,13 @@ export class AccountHandler {
invalidDomain?: boolean;
hasLinkedAccount?: boolean;
hasPassword?: boolean;
passwordlessSupported?: boolean;
} = {
exists: false,
invalidDomain: undefined,
hasLinkedAccount: undefined,
hasPassword: undefined,
passwordlessSupported: undefined,
};

try {
Expand All @@ -1623,6 +1625,8 @@ export class AccountHandler {
result.exists = true;
result.hasLinkedAccount = (account.linkedAccounts?.length || 0) > 0;
result.hasPassword = account.verifierSetAt > 0;
// Passwordless is supported if account has no password set
result.passwordlessSupported = account.verifierSetAt === 0;
} else {
const exist = await this.db.accountExists(email);
if (!exist) {
Expand All @@ -1642,6 +1646,14 @@ export class AccountHandler {
if (checkDomain) {
result.invalidDomain = invalidDomain;
}
// For non-existent accounts, check if passwordless is supported
// Either by matching the forced email regex (for testing) or if globally enabled
if (thirdPartyAuthStatus) {
const isPasswordlessForced =
this.config.passwordlessOtp.forcedEmailAddresses?.test(email);
result.passwordlessSupported =
isPasswordlessForced || this.config.passwordlessOtp.enabled;
}
if (this.customs.v2Enabled()) {
await this.customs.check(request, email, 'accountStatusCheckFailed');
}
Expand Down Expand Up @@ -2305,12 +2317,7 @@ export class AccountHandler {
const account = await this.db.account(uid);
const email = account.primaryEmail?.email;

await this.customs.checkAuthenticated(
request,
uid,
email,
'metricsOpt'
);
await this.customs.checkAuthenticated(request, uid, email, 'metricsOpt');

await Account.setMetricsOpt(uid, state);
await this.profileClient.deleteCache(uid);
Expand Down Expand Up @@ -2361,63 +2368,88 @@ export class AccountHandler {
const account = accountRecord.value;

// Format emails
const formattedEmails = emails.status === 'fulfilled'
? emails.value.map((email: { email: string; isPrimary: boolean; isVerified: boolean }) => ({
email: email.email,
isPrimary: email.isPrimary,
verified: email.isVerified,
}))
: [];
const formattedEmails =
emails.status === 'fulfilled'
? emails.value.map(
(email: {
email: string;
isPrimary: boolean;
isVerified: boolean;
}) => ({
email: email.email,
isPrimary: email.isPrimary,
verified: email.isVerified,
})
)
: [];

// Format linked accounts
const linkedAccounts = linkedAccountsResult.status === 'fulfilled'
? linkedAccountsResult.value.map((la: { providerId: number; authAt: number; enabled: boolean }) => ({
providerId: la.providerId,
authAt: la.authAt,
enabled: la.enabled,
}))
: [];
const linkedAccounts =
linkedAccountsResult.status === 'fulfilled'
? linkedAccountsResult.value.map(
(la: { providerId: number; authAt: number; enabled: boolean }) => ({
providerId: la.providerId,
authAt: la.authAt,
enabled: la.enabled,
})
)
: [];

// Format TOTP status
const totp = totpResult.status === 'fulfilled' && totpResult.value
? { exists: true, verified: !!totpResult.value.verified }
: { exists: false, verified: false };
const totp =
totpResult.status === 'fulfilled' && totpResult.value
? { exists: true, verified: !!totpResult.value.verified }
: { exists: false, verified: false };

// Format backup codes status
const backupCodes = backupCodesResult.status === 'fulfilled'
? backupCodesResult.value
: { hasBackupCodes: false, count: 0 };
const backupCodes =
backupCodesResult.status === 'fulfilled'
? backupCodesResult.value
: { hasBackupCodes: false, count: 0 };

// Calculate estimated sync device count (for recovery key promo eligibility)
const devicesCount = devicesResult.status === 'fulfilled' ? devicesResult.value.length : 0;
const authorizedClients = authorizedClientsResult.status === 'fulfilled' ? authorizedClientsResult.value : [];
const devicesCount =
devicesResult.status === 'fulfilled' ? devicesResult.value.length : 0;
const authorizedClients =
authorizedClientsResult.status === 'fulfilled'
? authorizedClientsResult.value
: [];
const syncOAuthClientsCount = authorizedClients.filter(
(client: { scope?: string }) => client.scope && client.scope.includes(OAUTH_SCOPE_OLD_SYNC)
(client: { scope?: string }) =>
client.scope && client.scope.includes(OAUTH_SCOPE_OLD_SYNC)
).length;
const estimatedSyncDeviceCount = Math.max(devicesCount, syncOAuthClientsCount);
const estimatedSyncDeviceCount = Math.max(
devicesCount,
syncOAuthClientsCount
);

// Format recovery key status
const recoveryKey = recoveryKeyResult.status === 'fulfilled' && recoveryKeyResult.value
? { exists: true, estimatedSyncDeviceCount }
: { exists: false, estimatedSyncDeviceCount };
const recoveryKey =
recoveryKeyResult.status === 'fulfilled' && recoveryKeyResult.value
? { exists: true, estimatedSyncDeviceCount }
: { exists: false, estimatedSyncDeviceCount };

// Format recovery phone status
const recoveryPhoneData = recoveryPhoneResult.status === 'fulfilled'
? recoveryPhoneResult.value
: { exists: false, phoneNumber: null };
const recoveryPhoneData =
recoveryPhoneResult.status === 'fulfilled'
? recoveryPhoneResult.value
: { exists: false, phoneNumber: null };
const recoveryPhone = {
...recoveryPhoneData,
available: recoveryPhoneAvailable,
};

// Format security events
const securityEvents = securityEventsResult.status === 'fulfilled'
? securityEventsResult.value.map((e: { name: string; createdAt: number; verified?: boolean }) => ({
name: e.name,
createdAt: e.createdAt,
verified: e.verified,
}))
: [];
const securityEvents =
securityEventsResult.status === 'fulfilled'
? securityEventsResult.value.map(
(e: { name: string; createdAt: number; verified?: boolean }) => ({
name: e.name,
createdAt: e.createdAt,
verified: e.verified,
})
)
: [];

// Fetch subscriptions (separate block due to complexity)
let webSubscriptions: Awaited<WebSubscription[]> = [];
Expand Down Expand Up @@ -2802,6 +2834,7 @@ export const accountRoutes = (
hasLinkedAccount: isA.boolean().optional(),
hasPassword: isA.boolean().optional(),
invalidDomain: isA.boolean().optional(),
passwordlessSupported: isA.boolean().optional(),
}),
},
},
Expand Down
12 changes: 12 additions & 0 deletions packages/fxa-auth-server/lib/routes/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,17 @@ module.exports = function (
const { mfaRoutes } = require('./mfa');
const mfa = mfaRoutes(customs, db, log, mailer, statsd, config);

const { passwordlessRoutes } = require('./passwordless');
const passwordless = passwordlessRoutes(
log,
db,
config,
customs,
glean,
statsd,
authServerCacheRedis
);

let basePath = url.parse(config.publicUrl).path;
if (basePath === '/') {
basePath = '';
Expand All @@ -253,6 +264,7 @@ module.exports = function (
attachedClients,
emails,
password,
passwordless,
recoveryCodes,
recoveryPhone,
securityEvents,
Expand Down
Loading