Skip to content

feat(account): add expire param to account token endpoints - #13726

Open
HarshMN2345 wants to merge 30 commits into
mainfrom
codex/feat-13636-account-token-options
Open

HarshMN2345 wants to merge 30 commits into
mainfrom
codex/feat-13636-account-token-options

Conversation

@HarshMN2345

@HarshMN2345 HarshMN2345 commented Sep 16, 2026

Copy link
Copy Markdown
Member

What does this PR do?

Account tokens, verification codes and MFA challenges always use a fixed lifetime. This adds an optional expire parameter, in seconds, to the eight endpoints that create them, so apps can shorten that lifetime. The existing default is also the maximum for every caller, API keys and console sessions included. The minimum is 60 seconds, and an explicit null keeps the default.

Endpoint Default and maximum
POST /v1/account/tokens/magic-url 3600 (1 hour)
POST /v1/account/tokens/email 900 (15 minutes)
POST /v1/account/tokens/phone 900
POST /v1/account/recovery 3600
POST /v1/account/verifications/email 3600
POST /v1/account/verifications/email/otp 900
POST /v1/account/verifications/phone 3600
POST /v1/account/mfa/challenges 3600, all factors

Secrets are generated as before, and the response expire and the expire query param in email links are unchanged.

The magic URL, email OTP, email verification OTP and MFA emails keep relative wording, now with the real lifetime and correct plural forms per language: "This code will expire in 5 minutes.", "Он истечет через 5 минут.", "Platnost kódu vyprší za 1 minutu.", "21 分後に有効期限が切れます。" Whole hours read as hours, anything else as minutes rounded down.

  • Plurals: utopia-php/locale gains getPlural() and a plurals argument on getText() (feat: add plural translations utopia-php/locale#20). Phrases are ICU MessageFormat patterns formatted with CLDR plural rules, which ICU ships even with the English-only ICU data in our image. A phrase is formatted in the language of the sentence it fills, so English fallback sentences such as most previews get English phrases.
  • Translations: emails.expire.minutes and emails.expire.hours are generated from CLDR relative-time data for 67 files, with Hebrew and Bhojpuri written by hand. la, lb, sa, sd and sn have no usable CLDR data and fall back to English. The 154 expiry sentences now contain {{expire}}; each was checked with 1, 2, 5, 11, 15, 21 and 22 minutes and 1 and 2 hours. Strings in pa, or, si, sa and sn that were in another language are rewritten in the file's language.
  • Locale loading: app/init/locales.php passes the loaded file's name as the plural rules locale, so codes that load another file (e.g. sr loading en.json) use that file's rules.
  • Custom templates: they still get {{expire}}, formatted in the request language.

Two reference docs had wrong lifetimes and are corrected: email verification links last 1 hour, not 7 days, and phone verification codes last 1 hour, not 15 minutes.

Test Plan

New e2e tests:

  • testCreateTokenExpire covers magic URL, email and phone, on the client and API key suites.
  • testCreateRecoveryExpire covers the client and API key suites.
  • testCreateEmailVerificationExpire, testCreateEmailVerificationOTPExpire, testCreatePhoneVerificationExpire and testCreateMFAChallengeExpire (email and phone) cover the session endpoints.

Each one creates tokens with expire omitted, null, 60 and the maximum. It checks the lifetime, reads the delivered email or SMS, redeems it, and expects 400 general_argument_invalid for 59 and for the maximum + 1.

Run against a local stack built from this branch:

docker compose exec appwrite test tests/e2e/Services/Account/AccountCustomClientTest.php --filter='Expire|testEmailOTPSession|testCreateMagicUrl|testMFA'
docker compose exec appwrite test tests/e2e/Services/Account/AccountCustomServerTest.php --filter='Expire|testEmailOTPSession'
docker compose exec appwrite test tests/e2e/Services/Account/AccountConsoleClientTest.php --filter=testEmailOTPSession
  • Client suite: 13 tests, 657 assertions.
  • Server suite: 5 tests, 280 assertions.
  • Console testEmailOTPSession passes.
  • Locale check, Pint, Rector and PHPStan pass.
  • Every locale code rendered all 8 sentences for 1 minute to 1 hour with no leftover placeholders.
  • Checked the emails in maildev for en, ru, cs, ja and ar-ae.

Before merge: release utopia-php/locale with #20 and replace dev-feat-plurals as 0.9.0 in composer.json with the release.

Related PRs and Issues

Checklist

  • Have you read the Contributing Guidelines on issues?
  • If the PR includes a change to an API's metadata (desc, label, params, etc.), does it also include updated API specs and example docs? Endpoint metadata and reference docs are updated; CI generates the machine-readable specifications from that metadata.

@greptile-apps

greptile-apps Bot commented Sep 16, 2026

Copy link
Copy Markdown
Contributor

RetriggerConfidence Score: 4/5

The PR is not yet ready to merge because the unresolved prior feature-scope finding remains blocking, while the remaining translation-test concern is non-blocking.

Fix All in Claude CodeFindings

  1. P1 Token Configuration Was Removed
  2. P2 Tests Mirror Translation Copy
Fix with agent prompt
### Issue 1
app/controllers/api/account.php:undefined-2376
The changes since the previous review remove the `length` parameter from all eight account flows and cap `expire` at each flow's existing default for every caller. For example, this route no longer includes `length` in its REST, GraphQL, or SDK contract and rejects expiration values above 3,600 seconds. The same removal appears in the other account token routes and the MFA challenge route. As a result, callers cannot configure token length, and trusted callers cannot request the documented extended lifetime of up to one year, so the main feature promised by this PR is unavailable.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

### Issue 2
tests/e2e/Services/Account/TokensBase.php:110-115
These assertions hard-code the exact English pluralized text from the production translation templates. This violates the repository directive to test observable behavior instead of mirroring source text or configuration. The token lifetime is already checked independently through the returned creation and expiration timestamps, so these copy assertions add no independent expiry protection and will fail on harmless translation changes. This repository requirement must be satisfied before merging. The same issue appears in `tests/e2e/Services/Account/AccountBase.php:218` and `tests/e2e/Services/Account/AccountCustomClientTest.php:4436`.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Summary

This PR adds an optional expire parameter to eight account token, recovery, verification, and MFA challenge endpoints, bounded between 60 seconds and each flow’s existing lifetime. It also renders the effective lifetime in localized email content, updates reference documentation, and adds end-to-end coverage for defaults, explicit nulls, custom expiration, invalid boundaries, delivery, and redemption.

  • Keeps existing token lifetimes as both defaults and caller-independent maxima.
  • Adds plural-aware minute and hour phrases across locale catalogs.
  • Passes the loaded translation file’s locale into plural formatting.
  • Updates account and MFA endpoint metadata and reference documentation.
  • Expands account-flow regression coverage for expiration behavior.

Reviews (15) · Last reviewed commit: "Merge remote-tracking branch 'origin/mai..."

Comment thread app/controllers/api/account.php Outdated
Comment thread tests/e2e/Services/GraphQL/AccountTest.php Outdated
@github-actions

github-actions Bot commented Sep 16, 2026

Copy link
Copy Markdown

✨ Benchmark results

Comparing main (before) → codex/feat-13636-account-token-options (after).

Metric Before After Change
🚀 Requests/sec 284.51 277.96 -2.3%
⏱️ Latency P50 61.67 ms 63.22 ms +2.5%
⏱️ Latency P95 142.2 ms 144.72 ms +1.8%
Per-scenario breakdown & investigation details

Metrics below reflect the current branch (after). Δ P95 compares against the base.

Scenario P50 (ms) P95 (ms) Requests RPS Δ P95 (ms)
API total 63.22 144.72 17,157 277.96 +2.52
Account 116.02 220.16 903 15.05 -1.55
TablesDB 61.13 114.43 9,331 152.91 +2.06
Storage 58.39 129.49 4,515 75.74 +2.32
Functions 88.05 174.78 2,408 41.01 +3.5

Top API waits (after)

API request Max wait (ms)
account.get 308.19
functions.variables.update 307.19
storage.buckets.create 302.33
account.name.update 302.25
account.prefs.update 295.14

@HarshMN2345
HarshMN2345 marked this pull request as draft September 16, 2026 13:31
@HarshMN2345
HarshMN2345 marked this pull request as ready for review September 16, 2026 13:31
Comment thread tests/e2e/Services/Account/TokensBase.php Outdated
@HarshMN2345
HarshMN2345 marked this pull request as draft September 16, 2026 13:40
@HarshMN2345
HarshMN2345 marked this pull request as ready for review September 16, 2026 13:40
Comment thread tests/e2e/Services/Account/AccountCustomClientTest.php Outdated
Comment thread app/config/locale/templates/email-otp.tpl Outdated
Comment thread app/config/locale/templates/email-mfa-challenge.tpl Outdated
Comment thread app/config/locale/translations/en.json
Comment thread src/Appwrite/Platform/Modules/Account/Http/Account/MFA/Challenges/Create.php Outdated
Comment thread app/config/locale/translations/en.json Outdated
"emails.magicSession.preview": "Sign in to {{project}} with your secure link. Expires at {{expire}}.",
"emails.magicSession.hello": "Hello {{user}},",
"emails.magicSession.optionButton": "Click the button below to securely sign in to your {{b}}{{project}}{{/b}} account. This link will expire in 1 hour.",
"emails.magicSession.optionButton": "Click the button below to securely sign in to your {{b}}{{project}}{{/b}} account. Expires at {{expire}}.",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Lets pls have screenshot of devmail with this email, to mamake sure the value is readable well.

Because it was relative before. So we should keep it relative. But how would relative look? "Expires at 5 minutes"? Lets double check and fix if needed.

And lets keep locale in mind. Pluralization is pretty big problem, .. Maybe it wont be possible easily. Maybe we will need to switch to absolute time, but then lets have screenshot and get leads/eldad approval for this change.

@HarshMN2345 HarshMN2345 Sep 17, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Before / after / fr with expire: 300:

1-before-magic-url-en 3-after-magic-url-default-en 6-after-email-otp-expire300-fr

HarshMN2345 and others added 3 commits September 17, 2026 14:23
Drop the length option and its privileged ranges. Each endpoint now
accepts expire from 60 seconds up to its existing default lifetime for
every caller, and generates secrets exactly as before, so the code
templates no longer need to wrap long codes.

Emails show the expiry as a UTC date and time instead of the raw ISO
timestamp. The recovery and email verification link emails do not
render an expiry, so they no longer receive the variable.
Assert the default, explicit null, minimum and maximum lifetimes, the
delivered expiry, and redemption for each endpoint, and reject values
outside the range for client and API key callers. Remove the length and
GraphQL generator isolation cases along with the option.
@HarshMN2345 HarshMN2345 changed the title feat(account): configure token length and expiration feat(account): add expire param to account token endpoints Sep 17, 2026
->param('email', '', new EmailValidator(), 'User email.')
->param('url', '', fn ($redirectValidator) => $redirectValidator, 'URL to redirect the user back to your app from the magic URL login. Only URLs from hostnames in your project platform list are allowed. This requirement helps to prevent an [open redirect](https://cheatsheetseries.owasp.org/cheatsheets/Unvalidated_Redirects_and_Forwards_Cheat_Sheet.html) attack against your project API.', true, ['redirectValidator'])
->param('phrase', false, new Boolean(), 'Toggle for security phrase. If enabled, email will be send with a randomly generated phrase and the phrase will also be included in the response. Confirming phrases match increases the security of your authentication flow.', true)
->param('expire', TOKEN_EXPIRATION_CONFIRM, new Range(60, TOKEN_EXPIRATION_CONFIRM), 'Token expiration period in seconds. The default and maximum expiration is 1 hour.', true)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Token Configuration Was Removed

The changes since the previous review remove the length parameter from all eight account flows and cap expire at each flow's existing default for every caller. For example, this route no longer includes length in its REST, GraphQL, or SDK contract and rejects expiration values above 3,600 seconds. The same removal appears in the other account token routes and the MFA challenge route. As a result, callers cannot configure token length, and trusted callers cannot request the documented extended lifetime of up to one year, so the main feature promised by this PR is unavailable.

Knowledge Base Used:

Prompt To Fix With AI
This is a comment left during a code review.
Path: app/controllers/api/account.php
Line: 2346

Comment:
**Token Configuration Was Removed**

The changes since the previous review remove the `length` parameter from all eight account flows and cap `expire` at each flow's existing default for every caller. For example, this route no longer includes `length` in its REST, GraphQL, or SDK contract and rejects expiration values above 3,600 seconds. The same removal appears in the other account token routes and the MFA challenge route. As a result, callers cannot configure token length, and trusted callers cannot request the documented extended lifetime of up to one year, so the main feature promised by this PR is unavailable.

**Knowledge Base Used:**
- [Account authentication and identity](https://app.greptile.com/appwrite/-/custom-context/knowledge-base/appwrite/appwrite/-/docs/account-authentication.md)
- [API platform and request surface](https://app.greptile.com/appwrite/-/custom-context/knowledge-base/appwrite/appwrite/-/docs/api-platform.md)

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Fix in Claude Code Fix in Codex

@HarshMN2345

Copy link
Copy Markdown
Member Author

Looked into this. utopia-php/locale has no plural support, so I'd add it there rather than start a new library.

ICU already has CLDR plural rules for every language, even with the English-only ICU data in our image, so no image change is needed. What's missing is the localized "in X minutes" wording. Getting that at runtime needs icu-data-full (+30 MB, and it changes avatar initials), so I'd rather not.

Proposal: add getPlural($key, $count) to utopia-php/locale. Translation values use ICU plural syntax, e.g. ru {count, plural, one {через # минуту} few {через # минуты} many {через # минут} other {через # минуты}}. It applies the rules of the language that actually served the key, so an English fallback doesn't render "21 minute". It needs ext-intl, which I'd add as a suggest.

I'd generate emails.expire.minutes and emails.expire.hours once from CLDR's relative-time patterns, which gets case and word order right (Czech "za 1 minutu", Polish "za 22 minuty", Arabic duals). Sentences go back to "This code will expire {{expire}}." Hours when expire is a whole number of hours, otherwise minutes rounded down. Hebrew needs a small manual fix, and bh has no CLDR data, so it falls back to English.

The alternative, if you want the library intl-free, is key.one/key.few suffix keys with our own CLDR rules table. We'd have to maintain that table, and the locale CI check would fail because en.json won't have few/many keys.

Order: locale PR, release, then bump and translations in this PR. OK with this direction before I start?

Magic URL, email OTP, email verification OTP and MFA emails say when the
code or link expires relative to now, with plural forms from CLDR for
each language. Phrases are filled in the language of the sentence that
uses them, so English fallback sentences get English phrases. Whole
hours read as hours, anything else as minutes rounded down.

Translations that had drifted into another language (pa, or, si, sa, sn)
are rewritten in the file's language.
Comment on lines +110 to +114
$phrase = match ($expire) {
60 => 'in 1 minute',
900 => 'in 15 minutes',
3600 => 'in 1 hour',
};

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Tests Mirror Translation Copy

These assertions hard-code the exact English pluralized text from the production translation templates. This violates the repository directive to test observable behavior instead of mirroring source text or configuration. The token lifetime is already checked independently through the returned creation and expiration timestamps, so these copy assertions add no independent expiry protection and will fail on harmless translation changes. This repository requirement must be satisfied before merging. The same issue appears in tests/e2e/Services/Account/AccountBase.php:218 and tests/e2e/Services/Account/AccountCustomClientTest.php:4436.

Context Used: Call out and harshly judge implementation-coupled tests. We don't mirror source code, configuration, or version pins in assertions. We test observable behavior; use linters for syntax and schema checks. (source)

Prompt To Fix With AI
This is a comment left during a code review.
Path: tests/e2e/Services/Account/TokensBase.php
Line: 110-114

Comment:
**Tests Mirror Translation Copy**

These assertions hard-code the exact English pluralized text from the production translation templates. This violates the repository directive to test observable behavior instead of mirroring source text or configuration. The token lifetime is already checked independently through the returned creation and expiration timestamps, so these copy assertions add no independent expiry protection and will fail on harmless translation changes. This repository requirement must be satisfied before merging. The same issue appears in `tests/e2e/Services/Account/AccountBase.php:218` and `tests/e2e/Services/Account/AccountCustomClientTest.php:4436`.

**Context Used:** Call out and harshly judge implementation-coupled tests. We don't mirror source code, configuration, or version pins in assertions. We test observable behavior; use linters for syntax and schema checks. ([source](https://app.greptile.com/review/custom-context?memory=instruction-0))

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Fix in Claude Code Fix in Codex

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add expiry parameter to Magic URL and Email OTP

3 participants