User Authentication Foundation - RodrigoMattosoSilveira/EnterpriseRemoteSystems GitHub Wiki

Because Bite 28B intentionally provides the authentication foundation without the final login UX, perform these tests primarily with curl, the Authorization Admin page, Audit Logs, and SQLite.

1. Start the existing migrated application

Use the database on which migration 000042 already ran.

Terminal 1:

cd /Users/rodrigosilveira/projects/EnterpriseRemoteSystems

make local-backend

Terminal 2:

cd /Users/rodrigosilveira/projects/EnterpriseRemoteSystems

make local-frontend

Do not recreate the database.

Set the API variables:

BASE_URL="http://localhost:8080/api/v1"

LOGIN="[email protected]"
TEMP_PASSWORD='Temp-B28B-2026!'
CHANGED_PASSWORD='Changed-B28B-2026!'
RESET_PASSWORD='Reset-B28B-2026!'

COOKIE_A="/tmp/ers-b28b-a.cookies"
COOKIE_B="/tmp/ers-b28b-b.cookies"

rm -f \
  "$COOKIE_A" \
  "$COOKIE_B" \
  /tmp/ers-b28b-*.json \
  /tmp/ers-b28b-*.headers

#2. Confirm the local cookie configuration

make local-backend loads:

backend/.env

Check it:

grep '^AUTH_SESSION_COOKIE_SECURE=' backend/.env

Expected:

AUTH_SESSION_COOKIE_SECURE=false

If the setting is absent, development defaults to false. The runtime cookie inspection in step 6 is the authoritative confirmation.

3. Prepare a Person and Collaborator correctly

Use either:

  • An existing active Collaborator that does not already have an Authorization Actor; or
  • A disposable Person and Collaborator created for this test.

For a new test identity:

  1. Go to /people.
  2. Create a complete, active Person through the normal People workflow.
  3. Create an active Collaborator Journey for that Person through the normal Collaborator workflow.
  4. Confirm the Collaborator appears in /collaborators.

Do not attempt to create an Authorization Actor directly from a Person. The Authorization UI requires a Collaborator.

4. Create the Authorization Actor through the actual UI workflow

Open:

/admin/authorization

Ensure Admin request actor remains:

Actor ID / key: bootstrap-admin
Tenant ID: default```

**Under Create actor**:

1. Select the disposable Collaborator.
1. Confirm ERS displays Derived actor identity.
1. Confirm it derives:
   1. Actor key
   1. Display name
   1. Person ID
   1. Collaborator ID
1. Click Create Actor.

You do not enter or choose the actor key.

_**After creation, find the new actor in the Actors section and copy the generated actor key**_:

`USER_ACTOR_KEY="<generated-actor-key-shown-by-ERS>"`

Do not grant a role yet. An actor does not need a role merely to authenticate.
# 5. Retrieve the persisted Actor record ID

The authentication-account API requires the internal authz_actors.id, not the generated actor key.

**Retrieve it through the Authorization API**:
```bash
curl -fsS \
  -H "X-Actor-ID: bootstrap-admin" \
  -H "X-Tenant-ID: default" \
  "${BASE_URL}/authz/actors" \
  > /tmp/ers-b28b-actors.json

Inspect the selected actor:

jq \
  --arg key "$USER_ACTOR_KEY" \
  '.data[] | select(.actorKey == $key)' \
  /tmp/ers-b28b-actors.json
```b
**Set the persisted ID**:
```bash
USER_ACTOR_ID=$(
  jq -er \
    --arg key "$USER_ACTOR_KEY" \
    '.data[] | select(.actorKey == $key) | .id' \
    /tmp/ers-b28b-actors.json
)

echo "$USER_ACTOR_ID"

Confirm the returned actor contains:

  • The generated actor key.
  • A nonempty Person ID.
  • A nonempty Collaborator ID.
  • active: true.

6. Create the Authentication Account

Build the request:

jq -n \
  --arg actorId "$USER_ACTOR_ID" \
  --arg login "$LOGIN" \
  --arg password "$TEMP_PASSWORD" \
  '{
    actorId: $actorId,
    login: $login,
    temporaryPassword: $password,
    mustChangePassword: true
  }' \
  > /tmp/ers-b28b-create-account-request.json

Create the account:

HTTP_STATUS=$(
  curl -sS \
    -o /tmp/ers-b28b-account.json \
    -w '%{http_code}' \
    -X POST \
    -H "Content-Type: application/json" \
    -H "X-Actor-ID: bootstrap-admin" \
    -H "X-Tenant-ID: default" \
    --data-binary @/tmp/ers-b28b-create-account-request.json \
    "${BASE_URL}/auth/accounts"
)

echo "HTTP ${HTTP_STATUS}"
jq . /tmp/ers-b28b-account.json

Expected:

HTTP 201

Confirm:

  • .data.actorId equals USER_ACTOR_ID.
  • .data.actorKey equals USER_ACTOR_KEY.
  • .data.login is lowercase.
  • .data.active is true.
  • .data.actorActive is true.
  • .data.mustChangePassword is true.

Record the account ID:

ACCOUNT_ID=$(jq -er '.data.id' /tmp/ers-b28b-account.json)

echo "$ACCOUNT_ID"

One-account-per-actor protection

Attempt another account for the same actor with another login:

HTTP_STATUS=$(
  curl -sS \
    -o /tmp/ers-b28b-duplicate-actor.json \
    -w '%{http_code}' \
    -X POST \
    -H "Content-Type: application/json" \
    -H "X-Actor-ID: bootstrap-admin" \
    -H "X-Tenant-ID: default" \
    -d "{
      \"actorId\":\"${USER_ACTOR_ID}\",
      \"login\":\"second.${LOGIN}\",
      \"temporaryPassword\":\"${TEMP_PASSWORD}\"
    }" \
    "${BASE_URL}/auth/accounts"
)

echo "HTTP ${HTTP_STATUS}"
jq . /tmp/ers-b28b-duplicate-actor.json

Expected:

HTTP 409
error.code = actor_account_exists

A duplicate-login-across-two-actors test requires a second collaborator-backed actor. That behavior is already covered by the passing automated tests and does not require creating a second manual Person/Collaborator unless desired.

7. Verify password validation

Use the already-linked actor. Password validation runs before the account-link uniqueness check.

Too short

HTTP_STATUS=$(
  curl -sS \
    -o /tmp/ers-b28b-short-password.json \
    -w '%{http_code}' \
    -X POST \
    -H "Content-Type: application/json" \
    -H "X-Actor-ID: bootstrap-admin" \
    -H "X-Tenant-ID: default" \
    -d "{
      \"actorId\":\"${USER_ACTOR_ID}\",
      \"login\":\"[email protected]\",
      \"temporaryPassword\":\"too-short\"
    }" \
    "${BASE_URL}/auth/accounts"
)

echo "HTTP ${HTTP_STATUS}"
jq . /tmp/ers-b28b-short-password.json

Expected:

HTTP 400
error.code = validation_failed
error.fields.temporaryPassword identifies the 12-character minimum

More than 72 UTF-8 bytes

LONG_PASSWORD=$(python3 -c 'print("a" * 73)')

HTTP_STATUS=$(
  curl -sS \
    -o /tmp/ers-b28b-long-password.json \
    -w '%{http_code}' \
    -X POST \
    -H "Content-Type: application/json" \
    -H "X-Actor-ID: bootstrap-admin" \
    -H "X-Tenant-ID: default" \
    -d "{
      \"actorId\":\"${USER_ACTOR_ID}\",
      \"login\":\"[email protected]\",
      \"temporaryPassword\":\"${LONG_PASSWORD}\"
    }" \
    "${BASE_URL}/auth/accounts"
)

echo "HTTP ${HTTP_STATUS}"
jq . /tmp/ers-b28b-long-password.json

Expected:

HTTP 400
error.fields.temporaryPassword identifies the 72-byte maximum

8. Login and verify the session cookie

Use uppercase and spaces to prove login normalization:

HTTP_STATUS=$(
  curl -sS \
    -D /tmp/ers-b28b-login.headers \
    -o /tmp/ers-b28b-login.json \
    -w '%{http_code}' \
    -c "$COOKIE_A" \
    -H "Content-Type: application/json" \
    -d "{
      \"login\":\"  [email protected]  \",
      \"password\":\"${TEMP_PASSWORD}\"
    }" \
    "${BASE_URL}/auth/login"
)

echo "HTTP ${HTTP_STATUS}"
jq . /tmp/ers-b28b-login.json
grep -i '^set-cookie:' /tmp/ers-b28b-login.headers

Expected:

HTTP 200

Confirm .data contains:

  • accountId
  • actorId
  • actorKey
  • displayName
  • personId
  • collaboratorId
  • Normalized login
  • mustChangePassword: true
  • Future expiresAt

Confirm the cookie header contains:

  • HttpOnly
  • SameSite=Lax
  • Path=/

For local HTTP, it must not contain a Secure attribute:

if grep -qiE '^set-cookie:.*;[:space:](/RodrigoMattosoSilveira/EnterpriseRemoteSystems/wiki/:space:)*secure([;[:space:]]|$)' \
  /tmp/ers-b28b-login.headers; then
  echo "ERROR: local cookie unexpectedly has Secure"
else
  echo "Local cookie correctly omits Secure"
fi

The raw session token should appear only in the cookie, not in the JSON body.

9. Verify current-session behavior

With the cookie:

HTTP_STATUS=$(
  curl -sS \
    -D /tmp/ers-b28b-session.headers \
    -o /tmp/ers-b28b-session.json \
    -w '%{http_code}' \
    -b "$COOKIE_A" \
    "${BASE_URL}/auth/session"
)

echo "HTTP ${HTTP_STATUS}"
jq . /tmp/ers-b28b-session.json

Expected:

HTTP 200

Confirm:

grep -i '^cache-control: no-store' /tmp/ers-b28b-session.headers
grep -i '^pragma: no-cache' /tmp/ers-b28b-session.headers

Without a cookie:

HTTP_STATUS=$(
  curl -sS \
    -o /tmp/ers-b28b-no-session.json \
    -w '%{http_code}' \
    "${BASE_URL}/auth/session"
)

echo "HTTP ${HTTP_STATUS}"
jq . /tmp/ers-b28b-no-session.json

Expected:

HTTP 401
error.code = authentication_required

10. Verify generic login failures

Incorrect password:

curl -sS \
  -o /tmp/ers-b28b-wrong-password.json \
  -w 'HTTP %{http_code}\n' \
  -H "Content-Type: application/json" \
  -d "{
    \"login\":\"${LOGIN}\",
    \"password\":\"incorrect-password\"
  }" \
  "${BASE_URL}/auth/login"

jq . /tmp/ers-b28b-wrong-password.json

Unknown login:

curl -sS \
  -o /tmp/ers-b28b-unknown-login.json \
  -w 'HTTP %{http_code}\n' \
  -H "Content-Type: application/json" \
  -d '{
    "login":"[email protected]",
    "password":"incorrect-password"
  }' \
  "${BASE_URL}/auth/login"

jq . /tmp/ers-b28b-unknown-login.json
```bash
_**Both must return**_:

HTTP 401 error.code = invalid_credentials

The responses should not disclose whether the login exists.

# 11. Verify multiple sessions and password change

**Create a second session**:
```bash
curl -sS \
  -o /tmp/ers-b28b-login-b.json \
  -w 'HTTP %{http_code}\n' \
  -c "$COOKIE_B" \
  -H "Content-Type: application/json" \
  -d "{
    \"login\":\"${LOGIN}\",
    \"password\":\"${TEMP_PASSWORD}\"
  }" \
  "${BASE_URL}/auth/login"

Confirm both sessions work:

curl -sS -o /dev/null -w "Session A: %{http_code}\n" \
  -b "$COOKIE_A" \
  "${BASE_URL}/auth/session"

curl -sS -o /dev/null -w "Session B: %{http_code}\n" \
  -b "$COOKIE_B" \
  "${BASE_URL}/auth/session"

Expected:

Session A: 200
Session B: 200

Change the password using session A:

HTTP_STATUS=$(
  curl -sS \
    -D /tmp/ers-b28b-change-password.headers \
    -o /tmp/ers-b28b-change-password.body \
    -w '%{http_code}' \
    -X POST \
    -b "$COOKIE_A" \
    -c "$COOKIE_A" \
    -H "Content-Type: application/json" \
    -d "{
      \"currentPassword\":\"${TEMP_PASSWORD}\",
      \"newPassword\":\"${CHANGED_PASSWORD}\"
    }" \
    "${BASE_URL}/auth/password/change"
)

echo "HTTP ${HTTP_STATUS}"
```bash
_**Expected**_:

`HTTP 204`

_**Both old sessions must now fail**_:
```bash
curl -sS \
  -o /tmp/ers-b28b-revoked-a.json \
  -w 'Session A: %{http_code}\n' \
  -b "$COOKIE_A" \
  "${BASE_URL}/auth/session"

curl -sS \
  -o /tmp/ers-b28b-revoked-b.json \
  -w 'Session B: %{http_code}\n' \
  -b "$COOKIE_B" \
  "${BASE_URL}/auth/session"

Expected:

401 authentication_required

The old password must fail, while the new password succeeds.

After logging in with the new password, confirm:

mustChangePassword = false

mustChangePassword is currently a returned state flag. Bite 28B does not yet provide browser redirection or forced-change UX.

12. Verify one-time password-reset behavior

Log in with CHANGED_PASSWORD and retain a valid session in COOKIE_A.

Issue token A:

curl -sS \
  -o /tmp/ers-b28b-reset-a.json \
  -w 'HTTP %{http_code}\n' \
  -X POST \
  -H "X-Actor-ID: bootstrap-admin" \
  -H "X-Tenant-ID: default" \
  "${BASE_URL}/auth/accounts/${ACCOUNT_ID}/password-reset-tokens"

RESET_TOKEN_A=$(jq -er '.data.token' /tmp/ers-b28b-reset-a.json)

Issue token B:

curl -sS \
  -o /tmp/ers-b28b-reset-b.json \
  -w 'HTTP %{http_code}\n' \
  -X POST \
  -H "X-Actor-ID: bootstrap-admin" \
  -H "X-Tenant-ID: default" \
  "${BASE_URL}/auth/accounts/${ACCOUNT_ID}/password-reset-tokens"

RESET_TOKEN_B=$(jq -er '.data.token' /tmp/ers-b28b-reset-b.json)

Token A should now be invalid:

curl -sS \
  -o /tmp/ers-b28b-reset-old.json \
  -w 'HTTP %{http_code}\n' \
  -X POST \
  -H "Content-Type: application/json" \
  -d "{
    \"token\":\"${RESET_TOKEN_A}\",
    \"newPassword\":\"${RESET_PASSWORD}\"
  }" \
  "${BASE_URL}/auth/password/reset"

jq . /tmp/ers-b28b-reset-old.json

Expected:

HTTP 400
error.code = invalid_reset_token

Use token B:

HTTP_STATUS=$(
  curl -sS \
    -o /tmp/ers-b28b-reset-success.body \
    -w '%{http_code}' \
    -X POST \
    -H "Content-Type: application/json" \
    -d "{
      \"token\":\"${RESET_TOKEN_B}\",
      \"newPassword\":\"${RESET_PASSWORD}\"
    }" \
    "${BASE_URL}/auth/password/reset"
)

echo "HTTP ${HTTP_STATUS}"

Expected:

HTTP 204

Confirm:

  • The existing session is revoked.
  • CHANGED_PASSWORD no longer works.
  • RESET_PASSWORD works.
  • Reusing token B returns 400 invalid_reset_token.
  • Login after reset returns mustChangePassword: false.

13. Verify logout

Login using RESET_PASSWORD, then:

HTTP_STATUS=$(
  curl -sS \
    -D /tmp/ers-b28b-logout.headers \
    -o /tmp/ers-b28b-logout.body \
    -w '%{http_code}' \
    -X POST \
    -b "$COOKIE_A" \
    -c "$COOKIE_A" \
    "${BASE_URL}/auth/logout"
)

echo "HTTP ${HTTP_STATUS}"
grep -i '^set-cookie:' /tmp/ers-b28b-logout.headers

Expected:

HTTP 204

The cookie should be cleared with an expired date or immediate Max-Age.

The old cookie must return:

401 authentication_required

Calling logout with no valid cookie must still return:

204

Logout is intentionally idempotent.

14. Verify account deactivation and reactivation

Login and retain a valid cookie.

Issue a reset token before deactivation and record it:

curl -sS \
  -o /tmp/ers-b28b-pre-deactivate-token.json \
  -X POST \
  -H "X-Actor-ID: bootstrap-admin" \
  -H "X-Tenant-ID: default" \
  "${BASE_URL}/auth/accounts/${ACCOUNT_ID}/password-reset-tokens"

PRE_DEACTIVATE_TOKEN=$(
  jq -er '.data.token' /tmp/ers-b28b-pre-deactivate-token.json
)

Deactivate the account:

curl -sS \
  -o /tmp/ers-b28b-account-inactive.json \
  -w 'HTTP %{http_code}\n' \
  -X PATCH \
  -H "Content-Type: application/json" \
  -H "X-Actor-ID: bootstrap-admin" \
  -H "X-Tenant-ID: default" \
  -d '{"active":false}' \
  "${BASE_URL}/auth/accounts/${ACCOUNT_ID}/active"

jq . /tmp/ers-b28b-account-inactive.json

Expected:

HTTP 200
data.active = false

Because deactivation proactively revokes the sessions, the previous cookie should normally return:

HTTP 401
error.code = authentication_required

Login should return the generic:

HTTP 401
error.code = invalid_credentials

The reset token issued before deactivation must now return:

HTTP 400
error.code = invalid_reset_token

Reactivate:

curl -sS \
  -o /tmp/ers-b28b-account-active.json \
  -w 'HTTP %{http_code}\n' \
  -X PATCH \
  -H "Content-Type: application/json" \
  -H "X-Actor-ID: bootstrap-admin" \
  -H "X-Tenant-ID: default" \
  -d '{"active":true}' \
  "${BASE_URL}/auth/accounts/${ACCOUNT_ID}/active"

Expected:

HTTP 200
data.active = true

Login with the last valid password should work again.

15. Verify Authorization Actor deactivation

Keep bootstrap-admin as the operating actor in /admin/authorization.

  1. Log in using the disposable authentication account.
  2. In the Actors section, deactivate the disposable collaborator-backed actor.
  3. Do not change the Admin request actor to the disposable actor.

Call /auth/session with the cookie from before actor deactivation.

Expected on the first resolution:

HTTP 401
error.code = actor_inactive

The response should clear the cookie, and the database session is revoked.

A new login should return: `` HTTP 401 error.code = invalid_credentials

Reactivate the Authorization Actor in the UI.

The old session must remain invalid. A new login should succeed.

# 16. Verify authentication-account administration scope
## Session alone

**Call account administration using only a session cookie**:
```bash
curl -sS \
  -o /tmp/ers-b28b-session-only-accounts.json \
  -w 'HTTP %{http_code}\n' \
  -b "$COOKIE_A" \
  "${BASE_URL}/auth/accounts"

jq . /tmp/ers-b28b-session-only-accounts.json
```bash
_**Expected for Bite 28B**_:

HTTP 401 error.code = missing_actor

## Tenant Admin scope

**In /admin/authorization, using bootstrap-admin as the operating actor**:

1. Grant the disposable actor:
   1. Role: TENANT_ADMIN
   1. Grant tenant: default
1. Keep the Admin request actor set to bootstrap-admin.

**Then call**:
```bash
curl -sS \
  -o /tmp/ers-b28b-tenant-admin-accounts.json \
  -w 'HTTP %{http_code}\n' \
  -H "X-Actor-ID: ${USER_ACTOR_KEY}" \
  -H "X-Tenant-ID: default" \
  "${BASE_URL}/auth/accounts"

jq . /tmp/ers-b28b-tenant-admin-accounts.json
```bash
_**Expected**_:
``
HTTP 403
error.code = forbidden

This proves tenant-scoped wildcard authority cannot manage authentication accounts.

Revoke the disposable TENANT_ADMIN grant afterward.

17. Verify self-deactivation protection

Using bootstrap-admin as the operating actor:

  • Grant the disposable actor:
    • Role: APPLICATION_ADMIN
    • Grant tenant: *
  • Keep the UI’s operating actor as bootstrap-admin.

Attempt to deactivate the disposable actor’s own authentication account while authorizing the request with that disposable actor:

curl -sS \
  -o /tmp/ers-b28b-self-deactivate.json \
  -w 'HTTP %{http_code}\n' \
  -X PATCH \
  -H "Content-Type: application/json" \
  -H "X-Actor-ID: ${USER_ACTOR_KEY}" \
  -H "X-Tenant-ID: default" \
  -d '{"active":false}' \
  "${BASE_URL}/auth/accounts/${ACCOUNT_ID}/active"

jq . /tmp/ers-b28b-self-deactivate.json
```bash
_**Expected**_:

HTTP 403 error.code = self_deactivation_forbidden

**Verify the account is still active**:
```bash
curl -fsS \
  -H "X-Actor-ID: bootstrap-admin" \
  -H "X-Tenant-ID: default" \
  "${BASE_URL}/auth/accounts" \
  | jq \
      --arg id "$ACCOUNT_ID" \
      '.data[] | select(.id == $id)'

Revoke the disposable APPLICATION_ADMIN grant afterward.

18. Verify the Bite 28C boundary

This is an intentional boundary, not a defect.

Login and call a protected business API using only the authentication cookie:

curl -sS \
  -o /tmp/ers-b28b-session-only-people.json \
  -w 'HTTP %{http_code}\n' \
  -b "$COOKIE_A" \
  "${BASE_URL}/people"

jq . /tmp/ers-b28b-session-only-people.json

Expected:

HTTP 401
error.code = missing_actor

Call the same API using the existing authorization mechanism:

curl -sS \
  -o /tmp/ers-b28b-header-people.json \
  -w 'HTTP %{http_code}\n' \
  -H "X-Actor-ID: bootstrap-admin" \
  -H "X-Tenant-ID: default" \
  "${BASE_URL}/people"

Expected:

HTTP 200

This proves:

Bite 28B authentication works. Bite 27 header-based authorization still works. Bite 28C has not been applied prematurely.

19. Verify audit records

Open:

/admin/audit-logs

Filter for the authentication account ID and verify authorized records for:

  • authentication.accounts.create
  • authentication.accounts.deactivate
  • authentication.accounts.activate
  • authentication.password_reset_tokens.issue

Confirm:

  • Actor is bootstrap-admin, except for any operation intentionally performed by the disposable Application Admin.
  • Target type is auth_user_account.
  • Target ID is ACCOUNT_ID.
  • Decision is authorized.
  • Request method and path match the operation.

Do not expect the current Authorization audit log to contain normal login, logout, password-change, or public reset operations. Bite 28B only audits account-administration operations through the existing Authorization audit mechanism.

20. Verify secrets are not stored in plaintext

Use the database path printed by make local-backend.

For the normal local value:

DATABASE_PATH=data/app.db

the project-root path is:

DB="backend/data/app.db"

Inspect the authentication account:

sqlite3 "$DB" <<SQL
.headers on
.mode column

SELECT
  id,
  actor_id,
  login,
  substr(password_hash, 1, 4) AS hash_prefix,
  length(password_hash) AS hash_length,
  active,
  must_change_password
FROM auth_user_accounts
WHERE id = '${ACCOUNT_ID}';
SQL

Expected:

  • Password hash begins with a bcrypt prefix such as .
  • The password itself is not present.

Inspect sessions and reset tokens:

sqlite3 "$DB" <<SQL
.headers on
.mode column

SELECT
  substr(token_hash, 1, 12) AS token_hash_prefix,
  expires_at,
  revoked_at
FROM auth_sessions
WHERE account_id = '${ACCOUNT_ID}'
ORDER BY created_at DESC;

SELECT
  substr(token_hash, 1, 12) AS token_hash_prefix,
  expires_at,
  used_at,
  invalidated_at
FROM auth_password_reset_tokens
WHERE account_id = '${ACCOUNT_ID}'
ORDER BY created_at DESC;
SQL

Confirm:

  • Session hashes do not begin with ers_s_.
  • Reset-token hashes do not begin with ers_pr_.
  • Revoked sessions remain as historical records.
  • Used and invalidated reset tokens retain their lifecycle state.