Repository navigation
User Authentication Foundation
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.
Use the database on which migration 000042 already ran.
Terminal 1:
cd /Users/rodrigosilveira/projects/EnterpriseRemoteSystems
make local-backendTerminal 2:
cd /Users/rodrigosilveira/projects/EnterpriseRemoteSystems
make local-frontendDo not recreate the database.
Set the API variables:
BASE_URL="http://localhost:8080/api/v1"
LOGIN="b28b.manual.user@example.com"
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/.envExpected:
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.
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.
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.
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.jsonCreate 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.jsonExpected:
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"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.jsonExpected:
HTTP 409
error.code = actor_account_existsA 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.
Use the already-linked actor. Password validation runs before the account-link uniqueness check.
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\":\"short-password-test@example.com\",
\"temporaryPassword\":\"too-short\"
}" \
"${BASE_URL}/auth/accounts"
)
echo "HTTP ${HTTP_STATUS}"
jq . /tmp/ers-b28b-short-password.jsonExpected:
HTTP 400
error.code = validation_failed
error.fields.temporaryPassword identifies the 12-character minimum
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\":\"long-password-test@example.com\",
\"temporaryPassword\":\"${LONG_PASSWORD}\"
}" \
"${BASE_URL}/auth/accounts"
)
echo "HTTP ${HTTP_STATUS}"
jq . /tmp/ers-b28b-long-password.jsonExpected:
HTTP 400
error.fields.temporaryPassword identifies the 72-byte maximum
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\":\" B28B.MANUAL.USER@EXAMPLE.COM \",
\"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.headersExpected:
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:]]*secure([;[:space:]]|$)' \
/tmp/ers-b28b-login.headers; then
echo "ERROR: local cookie unexpectedly has Secure"
else
echo "Local cookie correctly omits Secure"
fiThe raw session token should appear only in the cookie, not in the JSON body.
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.jsonExpected:
HTTP 200
Confirm:
grep -i '^cache-control: no-store' /tmp/ers-b28b-session.headers
grep -i '^pragma: no-cache' /tmp/ers-b28b-session.headersWithout 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.jsonExpected:
HTTP 401
error.code = authentication_required
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.jsonUnknown login:
curl -sS \
-o /tmp/ers-b28b-unknown-login.json \
-w 'HTTP %{http_code}\n' \
-H "Content-Type: application/json" \
-d '{
"login":"unknown-b28b@example.com",
"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.
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.jsonExpected:
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.
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.headersExpected:
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.
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.jsonExpected:
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.
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.
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.
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.jsonExpected:
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.
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.
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}';
SQLExpected:
- 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;
SQLConfirm:
- 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.