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:
- Go to /people.
- Create a complete, active Person through the normal People workflow.
- Create an active Collaborator Journey for that Person through the normal Collaborator workflow.
- 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.
- Log in using the disposable authentication account.
- In the Actors section, deactivate the disposable collaborator-backed actor.
- 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.