ADR 0001 — Authentication model#

Status: Accepted (Phase 0, 2026-07-03) · Amended for real password model (Phase 1, 2026-07-14)

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:SigningKey swap 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 PasswordFormat column decides:
      • 2 (Encrypted, the current legacy default) → verified via the ported SqlMembershipPasswordVerifier.
      • 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:UseLegacyPasswordHash so 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 from AspNetRoles where IsPermission=1,
    • grp — group GUIDs from aspmnx_GroupUsers,
    • mod_admin — module keys from aspnet_Profile admin flags,
    • itaraspnet_Profile.IsItarAuthorized.

Confirmed password model (Phase 1)#

The legacy system uses SqlMembershipProvider with passwordFormat="Encrypted". Passwords are not hashed — they are AES-encrypted using the ASP.NET machineKey (validationKey + decryptionKey from Web.config / machine.config), with a per-user salt from AspnetMembership.PasswordSalt.

Verification flow in Cube 2.0:

  1. Look up aspnet_Users by LoweredUserName.
  2. Load AspnetMembership.Password, PasswordSalt, PasswordFormat.
  3. If Password starts with $2 → BCrypt verify.
  4. Else if PasswordFormat == 2 → port ASP.NET’s algorithm: HMACSHA1(validationKey, saltBytes || passwordBytes) → AES-encrypt(decryptionKey, hash) → Base64 → compare to stored value.
  5. Else if PasswordFormat == 1 → SHA1 comparison (rare).
  6. On success and stored format is not BCrypt: rehash-on-login — BCrypt the plaintext (which we already have from the submitted password), update AspnetMembership.Password = bcryptHash + PasswordFormat = <sentinel> inside the same transaction as LastLoginDate.

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.Password column ends up mixed-format (BCrypt for rehashed rows, AES-encrypted for cold rows) during migration. The PasswordFormat column tells verification which path to take.
  • Once every user has logged in at least once post-cutover, we can flip FeatureFlags:UseLegacyPasswordHash = false and 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.config is a dev/staging placeholder (confirmed with the team). Production machineKey lives on prod IIS and is fetched separately.