added: one-time password links, self-service passwords, and sign out everywhere #8

Merged
jak merged 8 commits from password-links into main 2026-10-23 15:20:04 -04:00
Owner

Replaces passwords that admins pass on with one-time password links. An admin sends a person a link, and the person chooses their own password there. The same link will work for email reset later. This follows the design we settled on in the thread.

What changes for people

  • Admins. "New user" creates an account with no password and shows a password link once. Each user's page has a Password section: whether they have a password, the link's state (waiting, expired, used, or none), a "Make / Replace password link" button, and a "Clear password" danger action. The users list marks accounts with no password.
  • Link holders. /password/{token} shows a read-only username field and a new-password field, so password managers save the new password for the right account. The heading says "Choose a password" or "Reset your password". When the form succeeds, Consent signs them in and sends them home. Expired, used and unknown links each get their own page. If the browser is signed in as someone else, the page only offers "Sign out and continue".
  • Signed-in people. The handle in the top bar leads to /account. There they can change their password by giving the current one, which keeps their existing sign-ins, and press "Sign out everywhere".
  • Operators. The admin API gains PUT/DELETE /admin/users/{subject}/password and POST /admin/users/{subject}/password-link. password is optional on create, and users report has_password. The CLI gains users password set|clear|link, and users create prints the created user. make compose-admin takes only ADMIN_USER and prints a link.

Rules the code enforces

Action Password Sign-ins Link
Make link unchanged unchanged replaced (lasts 24 h)
Use link replaced signed out everywhere, then this browser is signed in marked used
Clear (admin) removed signed out everywhere cancelled
Set (admin API/CLI) replaced signed out everywhere cancelled
Change (user, needs current) replaced unchanged unchanged
Sign out everywhere unchanged signed out everywhere unchanged

Only actions that sign everyone out cancel a link. An attacker who knows the password therefore can't cancel a reset in progress. Using a link claims it with one conditional update, so it works exactly once, even if the form is submitted twice at the same moment. Tokens are stored only as SHA-256 hashes.

Notes for review

  • Schema: edited in place in migration 1, with no new migration, since nothing is deployed. user.secret is nullable, and password_link has one row per user.
  • New service dependency: the service now needs PublicURL to build link URLs, and New rejects a missing or invalid one.
  • Departures from the design proposal:
    • Change password and Sign out everywhere live on a new /account page, reached from the handle in the top bar, rather than on the home page. I chose this to keep the home page about apps.
    • I didn't add an Account item to the main navigation, because TestNavigation expects members to see only "Apps".
  • Removed code and tests: the generated-password helper is gone, along with its test. The old admin tests for generated passwords are rewritten for links.
  • Store contracts: they follow the persistence guidance, but I wrote them to the design we agreed on in chat rather than presenting each one for approval first. They're in internal/service/store.go.
  • Testing:
    • make test passes.
    • I also ran a production-mode server from scratch: created a user with no password, made a link, used it, saw it refused the second time, signed in, reset by link, changed the password on /account, and signed out everywhere.
    • I didn't run make mock-deployment. It still creates its demo users with --password, which keeps working.

🤖 Generated with Claude Code

Replaces passwords that admins pass on with one-time password links. An admin sends a person a link, and the person chooses their own password there. The same link will work for email reset later. This follows the design we settled on in the thread. ## What changes for people - **Admins.** "New user" creates an account with no password and shows a password link once. Each user's page has a **Password** section: whether they have a password, the link's state (waiting, expired, used, or none), a "Make / Replace password link" button, and a "Clear password" danger action. The users list marks accounts with no password. - **Link holders.** `/password/{token}` shows a read-only `username` field and a `new-password` field, so password managers save the new password for the right account. The heading says "Choose a password" or "Reset your password". When the form succeeds, Consent signs them in and sends them home. Expired, used and unknown links each get their own page. If the browser is signed in as someone else, the page only offers "Sign out and continue". - **Signed-in people.** The handle in the top bar leads to `/account`. There they can change their password by giving the current one, which keeps their existing sign-ins, and press "Sign out everywhere". - **Operators.** The admin API gains `PUT`/`DELETE /admin/users/{subject}/password` and `POST /admin/users/{subject}/password-link`. `password` is optional on create, and users report `has_password`. The CLI gains `users password set|clear|link`, and `users create` prints the created user. `make compose-admin` takes only `ADMIN_USER` and prints a link. ## Rules the code enforces | Action | Password | Sign-ins | Link | |---|---|---|---| | Make link | unchanged | unchanged | replaced (lasts 24 h) | | Use link | replaced | **signed out everywhere**, then this browser is signed in | marked used | | Clear (admin) | removed | **signed out everywhere** | cancelled | | Set (admin API/CLI) | replaced | **signed out everywhere** | cancelled | | Change (user, needs current) | replaced | unchanged | unchanged | | Sign out everywhere | unchanged | **signed out everywhere** | unchanged | Only actions that sign everyone out cancel a link. An attacker who knows the password therefore can't cancel a reset in progress. Using a link claims it with one conditional update, so it works exactly once, even if the form is submitted twice at the same moment. Tokens are stored only as SHA-256 hashes. ## Notes for review - **Schema:** edited in place in migration 1, with no new migration, since nothing is deployed. `user.secret` is nullable, and `password_link` has one row per user. - **New service dependency:** the service now needs `PublicURL` to build link URLs, and `New` rejects a missing or invalid one. - **Departures from the design proposal:** - Change password and Sign out everywhere live on a new `/account` page, reached from the handle in the top bar, rather than on the home page. I chose this to keep the home page about apps. - I didn't add an Account item to the main navigation, because `TestNavigation` expects members to see only "Apps". - **Removed code and tests:** the generated-password helper is gone, along with its test. The old admin tests for generated passwords are rewritten for links. - **Store contracts:** they follow the persistence guidance, but I wrote them to the design we agreed on in chat rather than presenting each one for approval first. They're in `internal/service/store.go`. - **Testing:** - `make test` passes. - I also ran a production-mode server from scratch: created a user with no password, made a link, used it, saw it refused the second time, signed in, reset by link, changed the password on `/account`, and signed out everywhere. - I didn't run `make mock-deployment`. It still creates its demo users with `--password`, which keeps working. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
The user table now allows a missing password, and users report whether they
have one. A new password_link table holds at most one link per user. It keeps
a hash of the token, when the link expires, and when it was used.

The store gained the operations the link design needs. Using a link claims it
with one conditional update, so a link sets a password at most once. It then
replaces the password and signs the user out everywhere. Setting or clearing a
password also cancels the user's link. Changing a password replaces it only
while the checked hash is still current, and leaves sessions and links alone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The service can now create a user without a password, clear a password, and
let a signed-in user change their own. Users can also sign out everywhere.
Admins can make password links. A link can be reviewed, and using one signs
the user in to Consent again.

The service now takes Consent's public URL, because a password link's URL is
built from it. Login and link use share one sign-in helper. Login refuses a
user without a password with the same error as a wrong password.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A password link opens /password/{token}. The page shows the account's handle
in a read-only username field above a new-password field, so password
managers save the new password for the right account. Its heading depends
on whether the account already has a password. Using the link signs the
person in and sends them home.

When the browser is signed in to a different account, the page offers only
to sign out and come back to the link. Expired, used, and unknown links each
get their own explanation.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The handle in the top bar now leads to /account. There, a signed-in person
can change their password by giving the current one. Changing it keeps
every existing sign-in, and the confirmation points to signing out
everywhere for anyone worried that someone else knows the old password.

Signing out everywhere signs out this browser and ends every refresh token
the account holds. Apps that are already open keep working until their
access tokens expire.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Creating a user on the admin dashboard now makes an account with no
password and shows a password link, once, for the administrator to send.
The user page has a Password section. It shows whether the account has a
password and the state of its link, and offers to make or replace a link.

Making a link no longer changes anything about the account. To lock someone
out right away, an administrator clears the password from the user page.
That signs them out everywhere and cancels any link. The users list marks
accounts that have no password.

We removed the generated-password helper, since Consent no longer makes up
passwords.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Creating a user through the API no longer requires a password. A user
created without one can't sign in until they use a password link. User
responses now include has_password, and 'consent api users create' prints
the created user.

New routes under /admin/users/{subject} set or clear a password, or make a
password link. The CLI exposes them as 'users password set', 'clear', and
'link'. Setting a password stays available because scripted setups, such as
the mock deployment, need accounts with known passwords.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
'make compose-admin' now takes only ADMIN_USER. It creates the account with
no password, makes it an administrator, and prints a password link for
choosing one. The password never passes through shell history or make
variables. The README's setup instructions follow the same flow, and it
describes how administrators and signed-in people manage passwords.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
In the service, password operations on an account moved into users.go and
password links into password_links.go. Revoking a user's refresh tokens
moved beside the other refresh token operations, in both the service and
the database, and is now named 'RevokeUserRefreshTokens'.

'CreateUser' now takes an optional password, so one method creates
accounts with and without one.

The change password form and the password link page now ask for the new
password twice. A mismatch shows an error and leaves the password and any
link unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
jak merged commit 7f44ca8c7b into main 2026-10-23 15:20:04 -04:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
pollinator/consent!8
No description provided.