Skip to content

Onboarding on Auth0

The steps a Manager or the operator follows to put a team on Winner AI when the installation signs in through Auth0. The team is one Auth0 Organization on your tenant, the one AUTH0_DOMAIN names, and the control plane's bootstrap organisation is that Organization.

On an installation from the bundle, read the server's commands as Commands on the other pages says: in <install>, as the user who owns <install>/.env, with plain docker compose and no sudo. Most blocks below are not server commands: they run in a shell signed in to the tenant (auth0 login; the block under A manager needs two more scopes), and need no server. They are written for bash: on a Mac, whose terminal runs zsh, type bash first, since zsh reads the # ... after a command as more of the command.

The organisation

One Auth0 Organization per installation, provisioned in the control plane before anyone signs in. It is the one AUTH0_TEST_ORG_ID (FLEETVIEW_BOOTSTRAP_ORG_ID) names, and the backend provisions it at every start. The installation serves that organisation alone; a manager of any other organisation the control plane once provisioned is refused at the Dashboard.

Who may sign in

The sign-in gate admits every session the identity provider admits and checks no group or role, so the tenant alone decides who reaches the control plane. Winner AI sets none of these settings for you.

  • The gate's application (AUTH0_PROXY_CLIENT_ID) requires an organisation: organization_usage: require with organization_require_behavior: pre_login_prompt, so the login page asks for the organisation first. Enable only this installation's Organization on it.
  • The Organization's enabled connection keeps assign_membership_on_login: false. Where database signup is open (the default), anyone who registers and verifies an address can name the Organization at the prompt and join it.
  • The callback and logout addresses. The gate's application lists https://<control-plane-host>/oauth2/callback among its Allowed Callback URLs and https://<control-plane-host> among its Allowed Logout URLs.

The post-login Action (below) also refuses a sign-in through any Organization but this installation's, or through none. It checks which Organization a sign-in came through, not how the person joined it, so both settings stay required.

Check it by hand after creating the application or changing its settings: sign in as a verified user who belongs to no organisation. Auth0 should stop them before any page of the control plane loads. The Action's "This installation does not serve the organization you signed in with" means the application does not require an organisation and the Action caught it; fix the setting all the same. Reaching Home, even its empty page, means a setting is wrong: read the application's organisation settings and each enabled connection's assign_membership_on_login.

People

This page is the Auth0 path. How any provider's people sign in, who is a Manager and how people join and leave is the identity provider guide.

Every person is an Auth0 user in the tenant's database connection, which must be named Username-Password-Authentication, the name Admin and the terminal block below create users in. Each is a member of exactly one organisation provisioned on this deployment, with one org-scoped role there. The control plane keeps a row per person in control.users and keeps its role true to Auth0 on its own, with no operator step. To remove someone, remove them from the organisation in Auth0 (Organizations, the org, Members) or delete the user; nothing on the host needs editing. Without Auth0 Management credentials no sync asks Auth0, so a Manager removes the person on Admin instead, and Restore brings them back as a Member. If the manager group names them, or, where no manager group is set or no usable groups claim arrives, FLEETVIEW_MANAGER_EMAILS does, their next request lifts them to Manager, so remove that entitlement before Restore for Member-only access.

One organisation per person. The installation serves one organisation, but the control plane may still hold rows of another it once provisioned. Admin resolves a person from their existing row by email and, failing that, from the first provisioned organisation Auth0 lists them in; enrollment refuses a person whose row belongs to another organisation. Adding an address that already has an Auth0 user adds that user to this organisation with the chosen role and no password; they sign in with their existing login or reset it. The add is refused, before anything changes in Auth0, when the address or its Auth0 user has a roster row in another organisation the control plane once provisioned, or when that user is an Auth0 member of one. Membership of an Auth0 organisation this deployment has not provisioned does not refuse. To see which organisation holds a refused address:

auth0 api get "users-by-email?email=<address>" | jq '.[].user_id'
auth0 api get "users/<user_id>/organizations" | jq '.[].id'

Then on the server, in <install>, open psql in the database container and look the row up:

docker compose exec db sh -c 'psql -U "$POSTGRES_USER" "$POSTGRES_DB"'
select u.id, u.email, o.auth0_org_id from control.users u
  join control.orgs o on o.id = u.org_id
  where lower(u.email) = '<address>' or u.auth0_user_id = '<user_id>';

Nothing in Admin moves a person between organisations. Add them under another address, or, when the other organisation's row is stale, delete their Auth0 user and the row, in the same psql, with the id the lookup printed (delete from control.users where id = '<id>';; a row that still has devices refuses, since its devices reference it), then add them again. Delete the user rather than only removing them from that organisation: the other organisation's manager may hold its temporary password, and a user this control plane created is linked without a verified address.

An existing login must be vouched for. Where database signup is open (the default), an Auth0 user for an address proves only that someone typed it. Admin links one only when Auth0 has verified the address, this control plane created the user, the user is already in this organisation, or their id is on this organisation's roster, revoked rows included. Otherwise the add is refused with nothing changed in Auth0. Have the person verify their address, which the verification email does:

jq -n --arg u "<user_id>" '{user_id:$u}' | auth0 api post jobs/verification-email

Delete that Auth0 user only once the person who holds the address confirms the login is not theirs, or it is confirmed stale and nobody uses it; otherwise verification is the path. An add creates a fresh user, with a temporary password, only when no Auth0 user holds the address; the users-by-email lookup above lists every one.

A manager

Two ways to add one: Admin hands a new user a temporary password, the terminal emails a set-password link.

Through Admin (/admin, "Add member", role manager), which needs a Manager already and the control plane's Management API credentials (AUTH0_MGMT_CLIENT_ID and AUTH0_MGMT_CLIENT_SECRET; without them the add fails): the control plane creates the user, if the address has none, with a temporary password shown once on the next page, adds them to the organisation and assigns the role. Hand the password over out of band; nothing makes the person change it. An existing user is added without a password and signs in with their existing login or resets it.

From a terminal, with the Auth0 CLI logged in to the tenant, when there is no manager yet or you would rather not see a password at all. ROLE_NAME is manager or member; the same block adds either.

The CLI's default login cannot add a member to an Organization or give them a role there. Log it in with the two scopes those steps need, which it requests beside its defaults; a CLI already logged in without them logs in again:

auth0 login --scopes "create:organization_members,create:organization_member_roles"

Then, in the same shell:

set +x                               # no shell tracing while a password is in a variable
ORG=<organization id>                # org_...
ROLE_NAME=manager                    # or member
EMAIL=<address>; NAME=<display name>
TENANT=<tenant domain>; PROXY_CLIENT_ID=<AUTH0_PROXY_CLIENT_ID>
ROLE=$(auth0 roles list --json | jq -er --arg n "$ROLE_NAME" '.[] | select(.name==$n) | .id')
PW="Fv-$(openssl rand -base64 18 | tr -d '/+=')!x9"   # never printed; replaced by the email below
USER_ID=

if [ -z "$ROLE" ]; then
  echo "no role named $ROLE_NAME: stop" >&2
elif [ "${#PW}" -lt 20 ]; then
  echo 'no password was generated: stop' >&2
# the password reaches jq on stdin, never as a process argument
elif ! USER_ID=$(printf '%s' "$PW" | jq -n --rawfile p /dev/stdin --arg e "$EMAIL" --arg n "$NAME" \
    '{connection:"Username-Password-Authentication", email:$e, name:$n, password:$p, email_verified:true}' \
  | auth0 api post users | jq -er .user_id); then
  echo 'the user was not created: stop' >&2
elif ! jq -n --arg u "$USER_ID" '{members:[$u]}' | auth0 api post "organizations/$ORG/members"; then
  echo "$USER_ID was created but not added to the organisation: stop" >&2
elif ! jq -n --arg r "$ROLE" '{roles:[$r]}' | auth0 api post "organizations/$ORG/members/$USER_ID/roles"; then
  echo "$USER_ID was added but given no role: stop" >&2
else
  # Auth0 emails a set-password link; the generated password is never used
  jq -n --arg c "$PROXY_CLIENT_ID" --arg e "$EMAIL" \
      '{client_id:$c, email:$e, connection:"Username-Password-Authentication"}' \
    | curl -sS --fail-with-body -X POST "https://$TENANT/dbconnections/change_password" \
        -H 'Content-Type: application/json' --data-binary @-
fi
unset PW

auth0 api post reads the body from stdin on purpose: --data hangs. Each step runs only once the one before it succeeded: jq -er fails on a missing id, and --fail-with-body on a non-2xx from Auth0. A failure prints which step stopped, and leaves your shell open with the variables set. A stop after the user was created names its id: a second run of the block would be refused because the user exists, so run the remaining lines by hand with that id. The control plane writes the person's row the first time they sign in, so nothing else is needed.

Why not Auth0 organisation invitations: the sign-in gate does not forward the invitation parameters to /authorize, so an invitee would reach the sign-in page without their invitation; the set-password email above gives the same experience without that hop.

Members and the first team

A member, a leaver and the first team are the same on every provider: the identity provider guide describes them. What stays here is what only an Auth0 installation does, which is creating the person in Auth0, in the two sections above.

Enrollment lands a laptop in the organisation of the person who approved its code at /login/approve, as that person: the laptop pass carries both, and the installation serves that one organisation. The proxy admits members as well as managers, so each person approves their own laptop's code. Anyone in another organisation is refused at sign-in by the post-login Action (below), and the Dashboard and the approval would refuse them too: /login/approve resolves only an organisation provisioned here. A winner from before the control plane's own login still uses the Auth0 device flow, whose token carries no organisation: it lands in the control plane's bootstrap organisation (the one AUTH0_TEST_ORG_ID names) and is refused for a person who is not a member of it. That check reads only the first page of members Auth0 returns, so in a larger organisation it may refuse a member too; a current winner is not affected.

The post-login Action

The tenant's post-login Action puts the roles and organisation claims on the tokens. Where database signup is open (the default), anyone can register an address a team's roster trusts without owning it; the Action therefore refuses, in the browser and in winner login alike, a sign-in unless Auth0 verified the address or the control plane created the account (its app_metadata tag, which the user cannot write). Admin's rule is wider: it also links an existing user already in this organisation or on its roster (An existing login must be vouched for, under People), and the Action still refuses that user at sign-in until the address is verified. For the sign-in gate's application only, it also refuses a sign-in through any Organization but this installation's, or through none: without Management credentials the gate's group decides who is a Manager, so another Organization enabled on that application would make its Managers Managers here. A winner that still uses the Auth0 device flow carries no Organization, so that winner login is not checked for one. A tenant runs the copy each release ships in its bundle, backend/auth0-post-login-claims.action.js, deployed by hand. Run these on the operator's own machine, not the server, which needs neither jq nor a tenant login: in the folder of the release's bundle unpacked there and checked with the release key, the signature and sha256sum, as Verify the bundle's signature shows (its server check, ./scripts/check-host.sh, is for the server, not this machine), from a shell signed in to the tenant (auth0 login). Auth0 takes the code as a JSON string, and auth0 api reads a body from stdin because --data hangs.

First: mark verified the accounts you created yourself and know, before the Action is deployed (the last list below says which, and how). Auth0's documentation says post-login Actions run on a refresh-token exchange, so a laptop still enrolled through the Auth0 device flow, for an account the Action would refuse, fails at its next hourly report.

Second: the Action's two secrets. The Action reads AUTH0_PROXY_CLIENT_ID, the gate's application, and AUTH0_TEST_ORG_ID, this installation's Organization, as Action secrets that hold the same values as the installation's .env lines of those names; keep the two in step. Every command below sends both with the code, so have the .env values to hand. Without either, or with one empty, the Action refuses every sign-in on the tenant, winner login included, and the next hourly report of every laptop still enrolled through the Auth0 device flow, whose report exchanges a refresh token (Auth0's documentation says post-login Actions run on that exchange; a laptop on the control plane's own pass is not affected); recover by running the update below with the values, or in the Auth0 dashboard. A wrong AUTH0_PROXY_CLIENT_ID skips only the Organization check: the Action does not recognise the gate and checks no Organization, so only the tenant settings in Who may sign in stand, while the verified-or-created check still refuses as before. The pair belongs to the Action, and the last update sent wins: a second installation's operator who updates the same Action on the same tenant replaces the first's pair, and it is then the first installation's gate that goes unchecked.

Deploy the release's copy. The first time on a tenant, create it; the trigger version v3 is the one Auth0 offers for post-login:

NAME=fleetview-roles                   # the name the tenant already uses, when it has one
PROXY_CLIENT_ID=<AUTH0_PROXY_CLIENT_ID from the installation's .env>
ORG_ID=<AUTH0_TEST_ORG_ID from the installation's .env>
if [ -z "$PROXY_CLIENT_ID" ] || [ -z "$ORG_ID" ]; then
  echo 'both secret values are needed: stop' >&2
else
  jq -n --arg n "$NAME" --rawfile c backend/auth0-post-login-claims.action.js \
        --arg p "$PROXY_CLIENT_ID" --arg o "$ORG_ID" \
      '{name:$n, supported_triggers:[{id:"post-login", version:"v3"}], code:$c,
        secrets:[{name:"AUTH0_PROXY_CLIENT_ID",value:$p},{name:"AUTH0_TEST_ORG_ID",value:$o}]}' \
    | auth0 api post actions/actions
fi

An action does nothing until it is deployed and bound to the Login flow. The bindings endpoint replaces the whole list, so a body holding only this Action would unbind every other post-login Action the tenant has (an MFA or policy Action). Build the body from the current bindings plus this Action, and read it before the PATCH; the list's order is the order the Actions run in, and this appends the Action last:

# the lookup filters by name; check the parameter against your tenant
ACTION_ID=$(auth0 api get "actions/actions?actionName=$(jq -rn --arg n "$NAME" '$n|@uri')" | jq -er --arg n "$NAME" '.actions[] | select(.name==$n) | .id')
if [ -z "$ACTION_ID" ]; then
  echo 'no action named that: stop' >&2
else
  echo '{}' | auth0 api post "actions/actions/$ACTION_ID/deploy"
  auth0 api get actions/triggers/post-login/bindings | jq .      # read what is bound now
  auth0 api get actions/triggers/post-login/bindings \
    | jq --arg id "$ACTION_ID" --arg n "$NAME" \
        '{bindings: ([.bindings[] | {ref:{type:"action_id", value:.action.id}, display_name:.display_name}]
          | if any(.ref.value == $id) then . else . + [{ref:{type:"action_id", value:$id}, display_name:$n}] end)}' \
    | tee /dev/stderr | auth0 api patch actions/triggers/post-login/bindings
fi

The body is printed to the terminal before it is sent. If any existing entry is missing from it, the field names differ on your tenant: stop and build the list by hand from the first command's output.

To update an action the tenant already has, replace its code and both secrets, and deploy. It sends both secrets every time, so no update depends on what Auth0 keeps from an earlier one. It stops with a message, rather than exiting the shell, when a value or the action is missing, so NAME and ACTION_ID stay set for the checks after it:

NAME=<the action's name>
PROXY_CLIENT_ID=<AUTH0_PROXY_CLIENT_ID from the installation's .env>
ORG_ID=<AUTH0_TEST_ORG_ID from the installation's .env>
ACTION_ID=$(auth0 api get "actions/actions?actionName=$(jq -rn --arg n "$NAME" '$n|@uri')" | jq -er --arg n "$NAME" '.actions[] | select(.name==$n) | .id')
if [ -z "$PROXY_CLIENT_ID" ] || [ -z "$ORG_ID" ]; then
  echo 'both secret values are needed: stop' >&2
elif [ -z "$ACTION_ID" ]; then
  echo 'no action named that: stop' >&2
else
  jq -n --rawfile c backend/auth0-post-login-claims.action.js \
        --arg p "$PROXY_CLIENT_ID" --arg o "$ORG_ID" \
    '{code:$c, secrets:[{name:"AUTH0_PROXY_CLIENT_ID",value:$p},{name:"AUTH0_TEST_ORG_ID",value:$o}]}' \
    | auth0 api patch "actions/actions/$ACTION_ID" \
    && echo '{}' | auth0 api post "actions/actions/$ACTION_ID/deploy"
fi

Compare what the tenant runs with the release's copy. Print the action and read its output: the code that runs is on its deployed version, the draft is the top-level code. Check the field names against your tenant's output before trusting a clean diff:

ACTION_ID=$(auth0 api get "actions/actions?actionName=$(jq -rn --arg n "$NAME" '$n|@uri')" | jq -er --arg n "$NAME" '.actions[] | select(.name==$n) | .id')
auth0 api get "actions/actions/$ACTION_ID" | jq .       # find the deployed version's code in this output
auth0 api get "actions/actions/$ACTION_ID" | jq -j '.deployed_version.code' > /tmp/deployed-action.js
diff /tmp/deployed-action.js backend/auth0-post-login-claims.action.js

No output from diff means they match. An empty or null file means the field path is wrong or the action was never deployed; take the code from the printed output instead. Do the compare after every release that changes the file and on every tenant a deployment uses.

Check the deployed Action holds both secrets. This reads the deployed version's secret names, the ones the running code sees, and never their values (the field path is from Auth0's documentation, not yet seen on a tenant):

auth0 api get "actions/actions/$ACTION_ID" | jq -r '.deployed_version.secrets[]?.name' | sort

Expect exactly AUTH0_PROXY_CLIENT_ID and AUTH0_TEST_ORG_ID; if it prints nothing, the deployed version holds no secrets or the field path differs on your tenant: find the secrets' names in the printed action above. Auth0 shows no secret's value back, so sign in to the control plane yourself afterwards: a wrong organisation id refuses you with "This installation does not serve the organization you signed in with". Nothing shows a wrong client id; when in doubt, run the update again with the .env values.

Which accounts need marking verified. Mark verified only accounts you created yourself and know. Marking one is vouching for its address; marking a stranger's self-signup does exactly what the Action exists to stop.

  • Created by Admin ("Add member"): nothing to do. The control plane tags what it creates, and the person signs in with the temporary password as before.
  • Created by the terminal block under "A manager" above: nothing, it sets email_verified: true.
  • Created another way, in the Auth0 dashboard or by an import, by you, for an address you know is the person's: mark it verified (the loop below), but only with your own record of the Auth0 user id that create returned. A lookup by address finds whoever holds it, a stranger's earlier self-signup included, even after your create answered 409. After a 409, or without that record, do not mark it: the account may be the person's own, used by other applications. Ask them to prove the address with the verification email under "An existing login must be vouched for" above, and delete an account only once you know it is not theirs. created_at does not tell such an account from a self-signup for the same address, so never use it as the evidence.
  • Anyone else who is unverified, including every self-signup: do not mark them. They prove the address with the verification email under "An existing login must be vouched for" above, or you delete them.

The listing below shows who the Action will refuse, not who to mark. It returns the first 100 users; add &page=1, &page=2 and so on for more. A search query stops at 1,000 matching records however far you page, so a larger tenant is not listed in full this way: narrow the query (by connection or by a created_at range in q) or use Auth0's bulk user export from the dashboard (the menu names vary; check yours).

auth0 api get 'users?q=email_verified%3Afalse&search_engine=v3&fields=email%2Capp_metadata%2Ccreated_at&include_fields=true&per_page=100&page=0' \
  | jq -r '.[] | select(.app_metadata.fleetview_created != true) | [.email, .created_at] | @tsv'
for USER_ID in '<user id>' '<user id>'; do   # the ids your own creates returned, never a lookup by address
  echo '{"email_verified":true}' | auth0 api patch "users/$USER_ID"
done

Check the listing's query syntax against your tenant if it returns an error or nothing. Where you keep a test tenant, do all of this there before deploying the Action on the tenant your team signs in with.

Checks

  • auth0 orgs members list <org id> lists everyone expected, and auth0 api get organizations/<org id>/members/<user id>/roles shows the role each should have.
  • Each manager signs in at https://<control-plane-host> and sees Home and the Dashboard; each member's winner login ends with the push service installed, and the roster (/admin) shows the laptop from enrollment. "Awaiting first push" there also means the roster could not read the org schema, so a push is proven by a session count or a last-push time.