26 KiB
Backend Phase 2 — Identity: phone-OTP auth, sessions & roles (REST)
Mission: give the marketplace its front door. The server already has a working phone-OTP/JWE/passwordless-TOTP engine and a dynamic-permission RBAC system — but it is reachable only over gRPC, with OTP delivery stubbed to a log line and no session, refresh-rotation, or role-selection surface. This phase wraps that existing machinery in REST:
/auth/otp/request,/auth/otp/verify,/auth/refresh,/auth/logout,/me,/me/role; adds revocableuser_sessionswith refresh-token rotation + stolen-token (reuse) detection; extendsuserswith the load-bearing identity columns (gender,national_idNULL-until-KYC,shahkar_verified_at); and wires the realISmsSenderseam so an OTP actually leaves the building (mock = log the code). Do not rebuild auth — reuseJwtService,AppUserManagerOTP, and the RBAC. After this phase every authenticated feature in the chain has something to authenticate against.Track: backend · Depends on: b0 (REST surface, rate limiter, cross-cutting seams), b1 (config accessor,
notifications, marketplace migration baseline) · Unlocks: every authenticated backend feature; frontend f1-b2 Before you start, read../_shared/agent-operating-rules.md. It is not optional.
1. Context — where this sits
This is backend phase b2, the root of the human-actor identity domain and the gate everything else
hangs off — no booking, profile, verification, or payment work can exist without it. The platform has
three actor types — customer (payer/family), nurse (caregiver/seller), admin (back-office) —
and phone number is the primary login credential via phone-OTP; email is optional and only required
for admin (product/business/01-actors-and-onboarding.md).
KYC is role-staged: a customer registers and browses with a verified phone alone; a nurse must later pass
the full verification pipeline (b6) before becoming bookable. This phase delivers login + session +
role selection — it deliberately stops short of profiles, patients, and bank accounts (those are b3).
What already exists (do not rebuild) — confirmed in the codebase:
- The whole auth engine. b0 kept the working spine: ASP.NET Core Identity,
JWE access tokens (
Jwt/JwtService : IJwtService—GenerateAsync(User),GenerateByPhoneNumberAsync,GetPrincipalFromExpiredToken,RefreshToken(Guid)), phone-OTP passwordless TOTP (AppUserManagerImplementation.GenerateOtpCode/VerifyUserCode,GeneratePhoneNumberConfirmationToken,ChangePhoneNumber; provider constants inIdentity/Dtos/CustomIdentityConstants), and the dynamic-permission RBAC (DynamicPermissionRequirement/Handler,DynamicPermissionService.CanAccess,ConstantPolicies.DynamicPermission,IRoleManagerService). Wrap these — never reimplement them. - The REST surface & rate limiter. b0 created the first versioned controllers
under
Baya.Web.Api/Controllers/V1/(sealed,BaseController, injectISender,[controller]/[action]snake_case tokens,base.OperationResult(...)), registeredLoggingBehavior, and wiredAddRateLimiterUseRateLimiter()with named per-IP policies ready for auth/OTP to apply. This phase applies those policies — it does not define new middleware.
- Cross-cutting seams. b0 introduced and DI-registered
IDateTimeProvider,IFieldEncryptor(encrypt/decrypt + deterministicHashfor lookups),ICacheService,IObjectStorage, andINotificationDispatcher. ReuseIFieldEncryptorfor the encryptedphone/email/national_idcolumns and for therefresh_token_hash; reuseIDateTimeProviderfor all expiry/timestamp math. Do not introduce a second clock or encryptor. - Config & the first migration baseline. b1 created the marketplace migration
baseline, the typed cached
platform_configsaccessor, and thenotificationswrite path behindINotificationDispatcher. Read OTP TTL / session lifetime / max-attempt knobs through the config accessor — never hardcode. Your new tables migrate onto b1's baseline. ICurrentUser+ audit interceptor. b0 wiredICurrentUser(Scoped, HTTP- context-backed, null-object for jobs/tests) and the SaveChanges interceptor that stampsCreatedAt/ModifiedAt/CreatedById/ModifiedById. Handlers never set audit fields by hand.- Identity tables & their config.
User : IdentityUser<int>,Role,UserRole,UserRefreshToken, the 8IEntityTypeConfigurations mapping Identity into theusrschema, andBaya.Tests.Setup(in-memory SQLite, full Identity stack incl. passwordless TOTP, realJwtService/AppUserManager). You extend these in place —users/roles/user_roleskeep their baseline columns and gain new ones;user_sessionsis a new table offusers.
What this phase introduces: the REST auth controllers, the users column extensions,
user_sessions (rotation + reuse detection), the role-selection flow over the existing RBAC, and
one new seam — ISmsSender (the OTP delivery rail; mock = log the code). The customer national-ID
KYC path, push/social login, and org self-onboarding are (DEFERRED) per the product doc — do not
build them.
2. Required reading (do this first)
../_shared/agent-operating-rules.mdand../_shared/backend-conventions-checklist.md.- Product — business truth.
product/business/01-actors-and-onboarding.md— phone-as-primary-credential, the customer↔patient split, role-staged KYC (national_id NULL until KYC), revocable sessions, MVP-vs-DEFERRED.product/overview/platform-summary.md— the four ground truths and where identity sits in the actor model. - Product — data model.
product/data-model/01-identity-and-access.md— the exactusers/user_sessions/roles/user_rolescolumns and constraints you must mirror (genderNVARCHAR(10) NULL,shahkar_verified_atreset-on-phone-change,phoneenc UNIQUE,user_sessions.refresh_token_hash/is_revoked/revoked_at/expires_at,user_roles.granted_by/granted_at/revoked_at). - Engineering truth.
server/CLAUDE.md— Identity & auth, Persistence, Startup wiring, Project map;server/CONVENTIONS.md— §1 routing, §4 controllers, §6 persistence (audit fields, soft-delete, encrypted PII), §11 security (rate limiting), §12 service registration. - Contract conventions you must honour.
../../contracts/conventions/api-conventions.md(envelope, status codes, auth header, pagination) and../../contracts/conventions/money-and-types.md(the gender enum is load-bearing —male/female, never defaulted/dropped). Section 8 publishesdev/contracts/domains/identity-auth.mdfrom the_TEMPLATE.md. - Prior handoff & registry.
dev/shared-working-context/backend/handoff/after-backend-phase-0.mdandafter-backend-phase-1.md(what is live), andreports/mocks-registry.md(theISmsSenderrow you will fill). - Code to read and mirror (do not duplicate):
Baya.Application/Features/Users/**(the existingUserCreateCommand,RefreshUserTokenCommand,RequestLogout,GenerateUserToken,TokenRequest/UserTokenRequestQuery— these already drive OTP/JWE and are your reuse targets; note the//TODO Send Code Via Sms Providerlog line you are replacing),Baya.Application/Features/Admin/**andBaya.Application/Features/Role/**(role/RBAC patterns),Baya.Infrastructure.Identity/Jwt/JwtService.cs,Baya.Infrastructure.Identity/AppUserManagerImplementation.cs, the existingUserGrpcServices(shows the request→IMediator→token shape you are re-exposing over REST),BaseController, andWebFramework/Swagger/RequireTokenWithoutAuthorizationAttribute(mark/auth/refreshwith it).
3. Scope — build this
Vertical slices: entity/migration → command/query handler → controller endpoint → contract. All
amounts/IDs follow project types (User.Id is int; UserRefreshToken is Guid-keyed). Reuse the
existing OTP/JWE/RBAC plumbing throughout — your handlers orchestrate IAppUserManager, IJwtService,
and the new session repository; they do not re-derive tokens or hash passwords.
3.1 Entity & migration changes
Add one EF migration (onto b1's baseline) covering:
- Extend
users(theusrschema Identity table — keep all baseline columns, add via theUserConfigconfiguration + entity properties onUser):genderNVARCHAR(10) NULL —male/female. Load-bearing for same-gender matching downstream; never defaulted or silently dropped. Not collected at OTP signup; settable later via profile (b3) — here it must merely exist and round-trip.national_id(enc) NULLABLE — stays NULL until KYC (b6). Do not add it to any signup/verify request.national_id_verified_atDATETIME2 NULL.shahkar_verified_atDATETIME2 NULL — reset to NULL on phone change (see §5). b6 sets it.phone(enc) — must be UNIQUE (one identity per phone).email(enc) NULLABLE.is_activeBIT anddeleted_atDATETIME2 NULL (soft-delete) with a global query filter (deleted_at IS NULL) — if the baselineUserlacks these, add them; otherwise confirm they map.- Encrypted columns route through
IFieldEncryptor; the UNIQUE onphoneuses a deterministicphone_hash(viaIFieldEncryptor.Hash) so an encrypted column can still be uniquely indexed — mirror theiban_hashpattern documented for b3.
- New
user_sessionsentity +IEntityTypeConfiguration(in aPersistence/Configuration/UserConfig/sibling,usrschema), offusers:id(PK),user_id(FK → users),refresh_token_hashNVARCHAR (store the hash viaIFieldEncryptor.Hash, never the raw token),device_infoNVARCHAR NULL,ip_addressNVARCHAR NULL,is_revokedBIT,revoked_atDATETIME2 NULL,expires_atDATETIME2 NOT NULL,created_at.- Index
(user_id, is_revoked)for fast active-session lookup; index onrefresh_token_hashfor rotation lookups.
- Extend
roles/user_roles(keep baseline RBAC columns):user_rolesgains the grant/revoke audit trail —granted_by(FK → users NULL),granted_atDATETIME2,revoked_atDATETIME2 NULL. Ensure the actor rolescustomer/nurseand the admin sub-roles exist (seedcustomer/nurseif the b1 seed did not; admin sub-rolessupport/finance/moderation/super_adminare seeded but only ever assigned internally).
Add an IUserSessionRepository contract in Application/Contracts/Persistence/ and expose it on
IUnitOfWork (mirror IUserRefreshTokenRepository); implement in Persistence.
3.2 Commands / queries (Baya.Application/Features/Identity/ — new area, or extend Users/)
Each is a record request + internal sealed handler; input-bearing commands get a FluentValidation
validator; expected failures return OperationResult (never throw). Reads are AsNoTracking() +
.Select() projections.
RequestOtpCommand(phone)→RequestOtpResult— normalize/validate the Iranian phone; upsert theusersrow (create an inactive-until-verified user if new, via the existingUserCreateCommandpath /IAppUserManager); callGenerateOtpCode; deliver viaISmsSender(replacing the//TODO Send Code Via Sms Providerlog). Return a non-enumerating result (e.g.otp_sent: true,resend_available_in_secondsfrom config) — do not leak whether the phone already existed.VerifyOtpCommand(phone, code, device_info?)→AuthTokensResult—VerifyUserCode; on success markphone_verified_at/is_active, mint a JWE access token (IJwtService.GenerateByPhoneNumberAsync) and a refresh token, and create auser_sessionsrow storingrefresh_token_hash,expires_at(from config),device_info,ip_address(fromICurrentUser/HTTP context). Return{ access_token, refresh_token, access_expires_at, refresh_expires_at, is_new_user, roles[] }. On a fresh userrolesis empty (drives the f1 role-router to/me/role).RefreshTokenCommand(refresh_token)→AuthTokensResult— look up the session byHash(refresh_token). Rotation + reuse detection: if the session is found and active → mark it revoked, mint a new access+refresh pair, create a new session (rotation). If the presented token hashes to a session that is already revoked → treat as stolen-token reuse: revoke all of that user's active sessions (logout-everywhere) and return401. Expired session →401. Mark the controller action withRequireTokenWithoutAuthorizationAttribute(it runs without a valid access token).LogoutCommand(refresh_token? | current session)→OperationResult— revoke the current session (is_revoked=true,revoked_at=now); rotate the security stamp via the existingRequestLogoutpath so the JWEOnTokenValidatedsecurity-stamp check rejects the old access token too. Optionaleverywhere: truerevokes all the user's sessions.GetMeQuery()→MeResult— current user (id, phone-masked, first/last name, gender,is_active), roles[], profile-completion flags (e.g.has_customer_profile,has_nurse_profile— false for now; b3 populates the underlying tables), and nurse verification status (read fromnurse_verifications.statuswhen present; until b6 it returnsnot_started/null— never read a removednurse_profiles.verification_status). Projection only;AsNoTracking().SelectRoleCommand(role)→MeResult— assigncustomerornurseto the current user viauser_roles(auditgranted_by= self / system,granted_at). Reject any admin sub-role with403— admin roles are internal-only and never self-assignable (§5). Idempotent if the role is already held. A user may hold bothcustomerandnurse.
3.3 Controllers (Baya.Web.Api/Controllers/V1/)
Sealed, inherit BaseController, inject ISender, return base.OperationResult(...), snake_case
[controller]/[action] routes, CancellationToken threaded. Apply the b0 rate-limit policies as noted.
| Method & route (snake_case) | Request → handler | Auth | Rate-limited |
|---|---|---|---|
POST api/v1/auth/otp/request |
RequestOtpCommand |
none | yes (per-phone + per-IP OTP policy) |
POST api/v1/auth/otp/verify |
VerifyOtpCommand |
none | yes (verify/brute-force policy) |
POST api/v1/auth/refresh |
RefreshTokenCommand |
RequireTokenWithoutAuthorization |
yes (refresh policy) |
POST api/v1/auth/logout |
LogoutCommand |
authenticated | no |
GET api/v1/me |
GetMeQuery |
authenticated | no |
POST api/v1/me/role |
SelectRoleCommand |
authenticated | no |
Routes render through the snake_case transformer; document the exact emitted paths in the contract from the published
swagger.json(don't assume the token expansion).
3.4 Wire ISmsSender for OTP delivery
Introduce ISmsSender (Application contract) with a real-shaped signature
(Task SendOtpAsync(string phone, string code, CancellationToken ct) plus a generic
SendAsync(string phone, string message, ...) for later transactional SMS). The mock Infrastructure
implementation logs the code (structured, never the full PII number in plaintext beyond what logging
policy allows) and returns success; selection is by config/registration (ServiceConfiguration/
extension), never an if (mock) in the handler. RequestOtpCommand calls it after GenerateOtpCode.
3.5 Out of scope (DEFERRED — do not build)
- Customer national-ID KYC (
customer_profiles.national_id_verified_at) — column deferred; never gate browsing on it. → deferred perproduct/business/01-actors-and-onboarding.md§(c). - Push-notification registration, social login, nursing-company (org) self-onboarding. → same doc.
- Profiles, patients, addresses, nurse bank accounts → b3.
- The
is_verifiedflip, Shahkar re-verification on phone change (the handler that re-runs Shahkar) → b6. This phase only resetsshahkar_verified_atto NULL when the phone changes; it does not run Shahkar.
4. Mocks & seams in this phase
| Seam | Owner | Mock behaviour | Registry |
|---|---|---|---|
ISmsSender |
introduced here | logs the OTP code (structured); returns success. Selected by config; real Kavenegar/Ghasedak client swaps in later (implement the same interface, keep idempotency/rate-limit logic). | add row → 🟡 |
IFieldEncryptor |
reuse (b0) | encrypt/decrypt phone/email/national_id, hash phone/refresh_token. |
no change |
IDateTimeProvider |
reuse (b0) | clock for OTP/session expiry. | no change |
ICacheService |
reuse (b0) | config-accessor cache (b1) only; not new here. | no change |
INotificationDispatcher |
reuse (b0/b1) | optional "new login" notification (in-app, b1 write). | no change |
Record ISmsSender in reports/mocks-registry.md:
seam name + file, what's faked, config keys (gateway api-key, sender line, OTP TTL), step-by-step how to
make it real (pick gateway, add client package to Directory.Packages.props, implement SendOtpAsync,
register over the mock by config), status 🟡.
5. Critical rules you must not get wrong
- Phone is the primary credential. OTP is the only public login. Email is optional and required only for admin — never make email mandatory, never make it a login key.
national_idis NOT collected at signup and stays NULL until KYC (b6). An unverified registration must never look KYC-complete. Do not addnational_idto any auth request body.shahkar_verified_atresets to NULL on phone change. Whenever the stored phone changes (viaChangePhoneNumber), null outshahkar_verified_atso b6 re-verifies. Don't silently keep a stale binding.- Sessions are revocable with refresh-token rotation + reuse detection. Each refresh rotates
(old session revoked, new one issued). A refresh presented against an already-revoked session is a
stolen-token signal → revoke all the user's sessions and
401. Store only the hash of the refresh token (IFieldEncryptor.Hash), never the raw token. Logout must invalidate the session server-side (and rotate the security stamp so the access token dies too). - Admin roles are NEVER self-assignable via the public
/me/role.SelectRoleCommandaccepts onlycustomer/nurse; any admin sub-role →403. Admin provisioning is internal-only and goes through the RBAC grant path (auditedgranted_by). - OTP is rate-limited and codes expire. Apply the b0 rate-limit policies to
otp/request,otp/verify, andrefresh. Read TTL/max-attempt/lockout knobs from the b1 config accessor. Treat brute-force / lockout as an explicit handled400/429state — never an unhandled exception. Do not enumerate accounts:otp/requestreturns the same shape whether or not the phone existed. users.genderis load-bearing (male/female) for same-gender matching — the column must exist and round-trip cleanly now even though it is populated later; never default it to a value or drop it.- Encrypt PII at rest (
phone,email,national_id) viaIFieldEncryptor; never log plaintext PII or project it into non-authorized responses (/memasks the phone). - Reuse, don't rebuild. Tokens come from
IJwtService; OTP fromIAppUserManager; RBAC from the existing permission system. No parallel JWT issuer, no second OTP generator, noif (mock)branches.
6. Definition of Done
The shared definition-of-done.md, plus:
- The six endpoints exist, are reachable through Swagger, and return the
OperationResultenvelope;otp/request/otp/verify/refreshare rate-limited;refreshcarriesRequireTokenWithoutAuthorization. usersis extended (gender,national_idenc NULL,national_id_verified_at,shahkar_verified_at,phoneenc UNIQUE via hash,emailenc NULL,is_active,deleted_at+ soft-delete query filter);user_sessionsand theuser_rolesaudit columns exist; one clean migration onto b1's baseline (dotnet buildzero new warnings,dotnet ef migrationsapplies).- Refresh rotation works and a replayed/old refresh token is rejected with all sessions revoked; logout invalidates the session and the access token (security-stamp rotation).
SelectRoleCommandassignscustomer/nurseonly; an admin sub-role attempt returns403.ISmsSenderis introduced behind DI, the mock logs the code, the registry row is added (🟡), andRequestOtpCommandcalls it (the old TODO log is gone).- Handler unit tests (NSubstitute) for verify/refresh/role + at least one
WebApplicationFactoryintegration test per endpoint area (happy path, 401 on/meunauthenticated, 400 on bad OTP, reuse-detection 401 on refresh, 403 on admin self-assign).dotnet test Baya.slngreen. - The Project map in
server/CLAUDE.mdnotes the newIdentityfeature area,user_sessions, the auth controllers, and theISmsSenderseam.
7. How to test (what a human can verify after this phase)
A reachable SQL Server is required. Run the API (dotnet run --project src/API/Baya.Web.Api/...), open
/swagger.
- OTP request logs a code.
POST api/v1/auth/otp/requestwith{ "phone": "09120000000" }→200{ otp_sent: true, ... }; the OTP code appears in the server log (theISmsSendermock). Repeat immediately past the limit →429. - Verify mints tokens + creates a session.
POST api/v1/auth/otp/verifywith the phone + the logged code →200withaccess_token,refresh_token, expiries,is_new_user: true,roles: []. Confirm auser_sessionsrow exists (is_revoked=false). /mereturns the user + roles.GET api/v1/mewithAuthorization: Bearer <access_token>→200with masked phone, emptyroles, profile-completion flags false, nurse verificationnot_started/null. Without the header →401.- Role selection.
POST api/v1/me/role{ "role": "customer" }→200,/menow shows["customer"].{ "role": "nurse" }also succeeds (a user can be both).{ "role": "super_admin" }→403. - Refresh rotates; replay is rejected.
POST api/v1/auth/refreshwith the refresh token →200new pair (old session now revoked). Replay the same old refresh token →401, and confirm all that user's sessions are revoked (stolen-token detection). - Logout kills the session and the access token.
POST api/v1/auth/logout→200; the prioraccess_tokennow fails/mewith401(security-stamp rotation), and the session isis_revoked. - Bad OTP.
otp/verifywith a wrong/expired code →400with a safe message (no enumeration).
These steps become the "what is now testable" section of your report.
8. Hand off & document (close the phase)
- Docs to update (same change):
server/CLAUDE.md— Project map (newFeatures/Identityarea,user_sessionstable + config, the V1 auth controllers, theISmsSenderseam and where it's registered) and Identity & auth (note OTP delivery is now real-seamed, sessions exist, role-selection flow). If you decided any rule not already in the product docs (e.g. the exact reuse-detection response), reflect it inproduct/business/01-actors-and-onboarding.md— record decisions, don't invent rules. - Contract to write: publish
dev/contracts/domains/identity-auth.mdfrom the_TEMPLATE.md— the six endpoints (routes, request/response shapes, auth, rate-limited flags, status codes incl.401reuse-detection and403admin self-assign), theroleenum (customer/nursepublic; admin sub-roles internal), theAuthTokensResult/MeResult/RequestOtpResultshared shapes (mark the masked phone), and the gender enum note. Then publish theswagger.jsonsnapshot peropenapi/README.mdso f1-b2 derives types from it, not by guessing. - Handoff & report: write
dev/shared-working-context/backend/handoff/after-backend-phase-2.md(auth is live over REST; what f1-b2 can now build — login, OTP, role router,AuthContextroles; what's mocked = SMS delivery), append tobackend/STATUS.md, writedev/shared-working-context/reports/backend-phase-2-report.md(what was built, what is testable and exactly how — the §7 steps, what is mocked + how to makeISmsSenderreal, theidentity-authcontract produced, follow-ups: profiles/patients/bank in b3, Shahkar/is_verifiedin b6), and updatereports/mocks-registry.md(theISmsSenderrow → 🟡). - Memory: save a
projectmemory note for the non-obvious decisions — how REST wraps the existing gRPC/OTP/JWE engine without duplication, the refresh-rotation + reuse-detection design (revoke-all on replay), thephone_hashUNIQUE-on-encrypted pattern, and the rule that admin roles are never self-assignable — with a one-lineMEMORY.mdpointer.