ADR 0001 — Authentication model#
Status: Accepted (Phase 0, 2026-07-03) · Amended for real password model (Phase 1, 2026-07-14) · Algorithm corrected against staging (Phase 1, 2026-09-04)
Context#
The legacy Cube 1.x uses ASP.NET Forms cookie authentication backed by AspnetUsers / AspnetMembership (classic ASP.NET Membership schema). We need a token-based scheme that works for browser SPAs, desktop clients, and service-to-service calls, and that can coexist with the legacy system during the migration.
Decision#
- Issue JWT access tokens signed with HS256 for development and lower environments; RS256 upgrade path via
Jwt:SigningKeyswap when a KMS-backed key is available. - One central Auth API endpoint set under
/api/v1/auth/(login,me,permissions). - Passwords are verified through
IPasswordHasher, which dispatches on the stored format:$2*prefix → BCrypt (new users / rehashed users, cost factor 11).- Otherwise → the row’s
PasswordFormatcolumn decides:2(Encrypted, the current legacy default) → verified via the portedSqlMembershipPasswordVerifier.1(Hashed) → SHA1(salt+password), for any historical rows using it.0(Clear) → rejected outright (should not exist in production).
- Legacy verification is gated by
FeatureFlags:UseLegacyPasswordHashso we can force a full cutover. - Claims carried on the token (see ADR 0005 for the full mapping):
sub,unique_name,jti(standard),role— permission strings fromAspNetRoleswhereIsPermission=1,grp— group GUIDs fromaspmnx_GroupUsers,mod_admin— module keys fromaspnet_Profileadmin flags,itar—aspnet_Profile.IsItarAuthorized.
Confirmed password model (Phase 1)#
The legacy system uses SqlMembershipProvider with passwordFormat="Encrypted" in Framework 2.0 compatibility mode. Passwords are AES-encrypted using the ASP.NET machineKey decryptionKey from Web.config / machine.config.
The PasswordSalt column is not used in Encrypted mode — it exists on aspnet_Membership for the Hashed-mode (PasswordFormat=1) code path. Confirmed against a real staging row (2026-09-04):
PasswordSaltlength: 24 base64 chars → 16 bytes — standard membership salt, but referenced only whenPasswordFormat=1.Passwordlength: 64 base64 chars → 48 bytes — three AES blocks. This precisely matches16 random-prefix + N password bytespadded to the next AES block boundary. For a 9-character password (18 bytes UTF-16 LE):16 + 18 = 34bytes → PKCS7 pad to 48 bytes → 48-byte ciphertext.
Encrypted-mode algorithm (Framework 2.0 compat, signData=false):
plaintext = randomPrefix(16) || password_UTF16LE
stored = Base64( AES-256-CBC-encrypt(decryptionKey, plaintext, iv=zeros, PKCS7) )The 16-byte random prefix is inside the encrypted payload, not a CBC IV — repeated encryption of the same password produces different ciphertext because CBC cascades the random first block. The AES operation itself uses a zero IV.
Verification flow in Cube 2.0:
- Look up
aspnet_UsersbyLoweredUserName. - Load
AspnetMembership.Password,PasswordSalt,PasswordFormat. - If
Passwordstarts with$2→ BCrypt verify. - Else if
PasswordFormat == 2→ AES-256-CBC decrypt the base64 payload withdecryptionKeyand a zero IV → strip the first 16 bytes of the recovered plaintext → constant-time compare the remaining bytes toEncoding.Unicode.GetBytes(submittedPassword). - Else if
PasswordFormat == 1→Base64(SHA1(saltBytes || passwordBytes_UTF16LE))compare. - On success and stored format is not BCrypt: rehash-on-login — BCrypt the plaintext and upsert into
MnxUserAuth(per ADR 0006) inside the same transaction that returns the token.
Why the earlier “HMAC then encrypt hash” wording was wrong#
The 2026-07-14 draft of this ADR described the algorithm as HMACSHA1(validationKey, salt||pwd) → AES-encrypt(hash). That was drawn from Framework 4.5+ MembershipPasswordCompatibilityMode.Framework40, which does involve an HMAC. But the staging DB rows are Framework 2.0 legacy — the byte layout confirmed this: a Framework 4.0 signed-and-encrypted payload for the same password would be 88 base64 chars, not 64. PasswordHasher.VerifyEncrypted implements the Framework 2.0 algorithm because that’s what our data uses. If a future site turns out to use Framework 4.0 mode, we add a second code path keyed on a config flag.
The machine-key values live as secrets in Machinekey:ValidationKey and Machinekey:DecryptionKey (env vars Machinekey__ValidationKey / Machinekey__DecryptionKey). Production values are held by ops — dev values match manexcloud/newSite/Web.config for local testing against seeded rows.
Consequences#
- Refresh tokens are not in scope for Phase 0 or Phase 1. Access token lifetime defaults to 60 min.
- Legacy password verification requires the machine key as a secret. Ops rotates it out-of-band; the app reads it via configuration.
- Rehash-on-login means the
AspnetMembership.Passwordcolumn ends up mixed-format (BCrypt for rehashed rows, AES-encrypted for cold rows) during migration. ThePasswordFormatcolumn tells verification which path to take. - Once every user has logged in at least once post-cutover, we can flip
FeatureFlags:UseLegacyPasswordHash = falseand stop reading the machine key at runtime. - Group / role / permission population is finalized in ADR 0005. Phase 0’s empty-array stubs are replaced in Phase 1.
- The Web.config machine key in
manexcloud/newSite/Web.configis a dev/staging placeholder (confirmed with the team). Production machineKey lives on prod IIS and is fetched separately.