---
name: amlang-registry-auth
description: How to authenticate against the AmLang extension registry at tucan.amlang.net — self-service sign-up with an emailed 4-digit code, admin-created accounts, logging in for a JWT access token, passing that token, roles and what each failure code means, plus the token contract for anyone wiring their own identity provider. Use when integrating an app or IDE against the registry, or when a request comes back 401, 403 or 429.
---

# Registry accounts and access tokens

Everything an integrator needs to authenticate against
`https://tucan.amlang.net`. For publishing extensions see
<https://tucan.amlang.net/extensions/SKILL.md>.

## Three ways a request is authenticated

| credential | how | who uses it |
|---|---|---|
| none | — | all reads: the catalog, versions, resource downloads |
| `Authorization: Bearer <jwt>` | access token from login | your users, your app on their behalf |
| `X-Admin-Token: <token>` | shared secret | your server, for administration |

A token is not only a way to read your own account: since writes moved onto the
public extension paths, it is what lets you create an extension and manage the
ones you own. Everything reserved to an administrator lives under `/admin`.

The admin token is a master key: it satisfies every role. Keep it server-side —
it is not something to ship to a client. Access tokens are what a client holds.

## 1. Get an account

Two routes in, and they differ in who does the creating.

### Self-service sign-up

Anyone can create an account, but it is not usable until the address on it is
confirmed: sign-up mails a **4-digit code**, and entering that code is what turns
the account on — and logs it in, since by then the holder has proved both the
password and the address.

```bash
curl -X POST "https://tucan.amlang.net/users/signup" \
  -H 'Content-Type: application/json' -d '{
    "username":    "anders",
    "email":       "anders@example.com",
    "password":    "correct horse battery staple",
    "displayName": "Anders"
  }'
```

```json
{"username":"anders","email":"anders@example.com","verification":"sent","expiresIn":1800}
```

Then, with the code from the mail:

```bash
curl -X POST "https://tucan.amlang.net/users/verify" \
  -H 'Content-Type: application/json' -d '{"username":"anders","code":"2452"}'
```

That returns the same token shape `/users/login` does — the account is now
verified and logged in. A fresh code, which replaces the outstanding one:

```bash
curl -X POST "https://tucan.amlang.net/users/verify/resend" \
  -H 'Content-Type: application/json' -d '{"username":"anders"}'
```

What the rules are, and why:

- **`username` is 3-32 of `A-Za-z0-9._-`**, because it ends up in URLs.
  **`password` is at least 8 characters. `email` must be unique** — a clash is a
  `409`, as is a taken username. Sign-up cannot hide whether a name is free; that
  answer is the point of the call.
- **The code lives 30 minutes and dies after 5 wrong guesses.** Four digits is
  10,000 possibilities, so the cap is what makes it safe: a locked code is dead,
  not merely disfavoured, and the way out is a new one, which is a new number to
  find. Resend is throttled to one a minute per account, sign-up to five an hour
  per IP address.
- **Until the code is entered the account grants nothing.** It holds no roles,
  owns no extensions, and its password is not a credential — `/users/login`
  answers `403 email not verified` rather than a token. That is the one place the
  login endpoint distinguishes a failure: past the password check it is talking to
  the account holder, and "go and read your mail" is more use than "try again".
- **No password reset yet.** The mail path exists now, so it is the obvious next
  thing to build on it.

Sign-up can be switched off entirely (`registry.signup_enabled=false`), leaving
the three routes answering `403` and the admin path below untouched. It also
answers `503` where the deployment has no mail or no signing key configured,
rather than creating accounts whose codes go nowhere.

### Created by an administrator

Your backend can create accounts directly, which skips verification entirely — an
administrator typing in someone's address is the confirmation:

```bash
curl -X POST "https://tucan.amlang.net/users" \
  -H "X-Admin-Token: $TOKEN" -H 'Content-Type: application/json' -d '{
    "username":    "anders",
    "password":    "correct horse battery staple",
    "email":       "anders@example.com",
    "displayName": "Anders"
  }'
```

`201` on create, `200` on update. `username` is the id and appears in URLs.

- **`password` is write-only.** It is hashed with PBKDF2-HMAC-SHA256 (per-user
  salt, 210k iterations) and never returned by any endpoint. The sign-up code is
  hashed the same way, for the same reason.
- **Updates merge.** An omitted field is left alone; `""` clears it. Omitting
  `password` leaves the existing one — it does not wipe it.
- **`roles`** is optional, e.g. `["Administrator"]`. Omit it for a normal user.
  Sign-up never reads roles from the request; only this endpoint sets them.
- **`email` is unique** — a clash returns `409`.
- **Accounts created here are verified on creation.** Posting
  `{"username":"anders","emailVerified":false}` reverses that and forces the
  account back through `/users/verify` — which needs a code, so pair it with a
  resend.
- Reading a user back shows `canLogIn` (password set, active *and* verified) and
  `emailVerified`.

To disable an account without deleting it: `{"username":"anders","active":false}`.
It keeps its extension memberships but can no longer log in.

## 2. Log in

```bash
curl -X POST "https://tucan.amlang.net/users/login" \
  -H 'Content-Type: application/json' \
  -d '{"username":"anders","password":"correct horse battery staple"}'
```

```json
{
  "accessToken":      "eyJhbGciOiJIUzI1NiJ9…",
  "tokenType":        "Bearer",
  "expiresIn":        86400,
  "refreshToken":     "Yy0xQk9…",
  "refreshExpiresIn": 31536000,
  "username":         "anders",
  "roles":            ["Administrator"]
}
```

Two tokens, with two jobs. The **access token** is what you send on every
request; it lasts 24 hours and cannot be called back, which is why it is short.
The **refresh token** lasts a year, is sent to exactly one endpoint, and *can* be
called back — so a lost machine is a revoked session rather than a password
change.

Every failure — unknown user, wrong password, disabled account, no password set —
returns the same `401` and the same message. That is deliberate: distinguishing
them tells an attacker which usernames exist.

The one exception is an unverified account, which answers `403` with
`{"error":"email not verified","verification":"required"}`. That is only reachable
with the right password, so it tells the caller nothing they did not just prove
they knew — and it is the difference between retrying the password and going to
read the mail.

## 3. Stay logged in

```bash
curl -X POST "https://tucan.amlang.net/users/token/refresh" \
  -H 'Content-Type: application/json' -d '{"refreshToken":"Yy0xQk9…"}'
```

The reply is the same shape as a login: a new access token **and a new refresh
token**. Store both; the one you sent is now spent.

- **Refresh on a schedule, not on a 401.** Halfway through the access token's
  life is a good rule — twice a day against a 24-hour token leaves a wide margin
  for a machine that was switched off, or a clock that is off.
- **Rotation is the point.** Every refresh consumes the token and issues its
  replacement, so a stolen copy works only until you next refresh. When a spent
  token is presented again, the whole session is revoked on the spot: one of the
  two callers holding it is not you, and there is no way to tell which, so both
  are stopped. A `401` with `"refresh":"rejected"` means log in again.
- **A retry is safe.** If the reply is lost in transit, sending the same token
  again within two minutes is treated as the same refresh rather than as reuse —
  you get a working token back instead of a forced logout.
- **Keep it like a password.** It is a bearer credential with a year on it: store
  it where you would store one, never in a project folder that might be
  committed, and never log it.

To end a session deliberately:

```bash
curl -X POST "https://tucan.amlang.net/users/logout" \
  -H 'Content-Type: application/json' -d '{"refreshToken":"Yy0xQk9…"}'
```

The access token already issued stays valid until it expires — nothing can
recall it — which is exactly why its life is measured in hours.

### Sessions

```bash
curl "https://tucan.amlang.net/users/me/sessions" -H "Authorization: Bearer $ACCESS_TOKEN"
curl -X DELETE "https://tucan.amlang.net/users/me/sessions/12" -H "Authorization: Bearer $ACCESS_TOKEN"
curl -X DELETE "https://tucan.amlang.net/users/me/sessions"    -H "Authorization: Bearer $ACCESS_TOKEN"
```

One row per machine that is signed in as you — what asked for it, where it was
last used from, when it expires. Deleting one signs that machine out; deleting
them all signs out everything, this session included. The `client` shown is the
`User-Agent` the login came with, so send something a person will recognise.

## 4. Pass the token

```bash
curl "https://tucan.amlang.net/users/me" \
  -H "Authorization: Bearer $ACCESS_TOKEN"
```

The `Bearer ` prefix is optional but conventional. A `token` cookie is accepted as
a fallback for browser clients.

`GET /users/me` returns the account the token names — the cheapest way to check a
token is still good and see its roles. `GET /users/me/extensions` returns what
that account participates in, invitations included:

```json
[{"extension":"am-git","extensionName":"am-git","role":"OWNER","status":"ACCEPTED",
  "invitedBy":"admin","invitedAt":"2026-09-19T11:56:51Z","respondedAt":"2026-09-19T11:56:51Z"}]
```

`status` is what separates *mine to work on* (`ACCEPTED`) from *asked to join*
(`INVITED`), so both arrive in one response. It is the same data
`GET /admin/users/{username}/extensions` gives an administrator, for the one user who
needs no permission to look: a client holding a token should not have to be told
its own username first.

The public `/extensions/{id}/v…` routes only show approved, active versions. To
read back your own work before an administrator approves it, use the same paths
under `/users/me/extensions/{id}`: the entry with its `approved` flag and newest
version, `/v` for every version whatever its state, `/v/{version}` for one, and
`/v/{version}/{platform}/r/{name}` for its file. Any accepted member may read
them, owner or not; everyone else gets 404.

## What's in the token

HS256, signed with the registry's `auth_secret`.

```json
{
  "sub":                "75",
  "preferred_username": "anders",
  "roles":              ["Administrator"],
  "iat":                1788282039,
  "exp":                1788325239,
  "iss":                "https://tucan.amlang.net",
  "aud":                "amlang-registry"
}
```

- **`preferred_username` is the link to the account.** It is matched against
  `User.username`; if no account matches, the token authenticates but resolves to
  nobody and account-scoped endpoints return `404`.
- **Roles are a snapshot.** They are copied in at login, so changing someone's
  roles takes effect on their *next* token, not immediately. Keep lifetimes short
  if that matters to you.
- **Expiry is enforced**, default 12 hours (`expiresIn` is seconds). There is no
  refresh token: log in again.
- There is no logout or revocation. A token is valid until it expires; to cut
  someone off sooner, set `active: false` (stops new logins) and rotate
  `auth_secret` (invalidates every existing token at once).

## Roles and what the codes mean

Endpoints are gated declaratively: `@AuthRequired` needs any authenticated
caller, `@RequireRoles` needs one of the named roles. `Administrator` is the only
role the API currently checks.

| code | meaning | what to do |
|---|---|---|
| `401` | no usable credential: missing, malformed, expired or wrongly-signed token | log in again |
| `403` | authenticated, but lacking the role — or, on login, a correct password for an unverified account | check `verification` in the body: `required` means enter the code, otherwise this account isn't allowed |
| `409` | on sign-up: the username or email is taken | pick another |
| `429` | too many wrong codes, resends, or sign-ups from one address | request a fresh code, or wait |
| `404` | on an account-scoped call: the token's username matches no account | register the user |
| `503` | login is unavailable because `auth_secret` isn't configured | server-side; see below |

The 401/403 split is the useful one: `401` means *try again with a better token*,
`403` means *the token is fine, the account isn't allowed*.

## What a user can do for themselves

Quite a lot, since writes moved onto the public extension paths. Signed in, you
can create an extension — you own what you create — and then do anything to it:
publish versions, upload files, invite others, remove them. What you cannot do is
approve it, which is what makes an open write path safe: your work is invisible
to everyone but you until an administrator approves it.

Answering an invitation is likewise yours alone, not your owner's.

```bash
curl -X POST "https://tucan.amlang.net/extensions/bebbossh/users" \
  -H "Authorization: Bearer $ACCESS_TOKEN" -H 'Content-Type: application/json' \
  -d '{"username":"bob"}'

curl -X POST "https://tucan.amlang.net/extensions/bebbossh/users/bob/accept" \
  -H "Authorization: Bearer $BOBS_TOKEN"
```

Answering someone else's invitation is `403` unless you hold the admin token.

## Bringing your own identity provider

The registry both issues and verifies tokens, but the verifier is independent of
the issuer: point `auth_secret` at a key your IdP signs with and it will accept
those tokens instead, no code change. It must emit HS256 and:

- a username in `preferred_username`, `username`, or the WS-Federation
  `…/claims/name` URI — matched against `User.username`
- roles in `roles`, `role`, or the WS-Federation `…/claims/role` URI, as a string
  or an array
- `exp`, which is enforced
- `iss` / `aud` matching `auth_issuer` / `auth_audience` if those are configured;
  they are skipped when blank

Accounts still have to exist here — a token identifies a user, it doesn't create
one. Provision them through `POST /admin/users` as your IdP onboards people.

## Server-side configuration

| setting | env | meaning |
|---|---|---|
| `auth_secret` | `AUTH_SECRET` | HS256 signing key. Blank ⇒ login returns `503` and every token is rejected |
| `auth_issuer` | `AUTH_ISSUER` | `iss` to set and require; blank ⇒ not checked |
| `auth_audience` | `AUTH_AUDIENCE` | `aud` to set and require; blank ⇒ not checked |
| `auth_token_ttl` | — | access-token lifetime in minutes, default 1440 (24h) |
| `auth_refresh_ttl_days` | — | refresh-token lifetime, default 365 |
| `auth_refresh_grace_seconds` | — | how long a just-rotated token still works, default 120 |
| `registry.admin_token` | `REGISTRY_ADMIN_TOKEN` | the shared admin key |
| `registry.signup_enabled` | — | `false` turns the three sign-up routes into a `403` |
| `registry.signup_code_ttl` | — | code lifetime in minutes, default 30 |
| `registry.signup_max_attempts` | — | wrong guesses before a code dies, default 5 |
| `registry.signup_resend_seconds` | — | shortest gap between codes, default 60 |
| `registry.signup_from` | `MAIL_FROM` | From: on the code mail. Blank ⇒ sign-up is `503` |
| `registry.signup_from_name` | `MAIL_FROM_NAME` | display name on that From:, e.g. `Tucan Studio`. Optional |
| `mail.smtp.host` etc. | `MAIL_HOST`, `MAIL_PORT`, `MAIL_USER`, `MAIL_PASS`, `MAIL_CHANNEL` | SMTP for that mail; the `mail-*` keys of `amlang-api-secret`, all optional |

`auth_secret` has to be at least 32 bytes — HS256 signs with a key no shorter
than its own output. A shorter one is treated as unset (login `503`) rather than
handed to the signer, which would fail inside the login response instead.

A deployment with no `mail-*` keys runs exactly as before, with sign-up
answering `503`; nothing else depends on mail.

The channel and the port have to agree: `587` is `starttls`, `465` is `ssl`. Play
forces port 465 whenever the channel is `ssl`, so `ssl` on 587 quietly talks to
the wrong port and the mail never leaves.

Upgrading an existing registry takes three steps in this order, because the two
migrations sit on opposite sides of the rollout:

1. `k8s/migrations/005-user-table.sql` — renames `registry_user` to `user` (and
   `registry_user_role` to `user_role`). **Before** the deploy: `jpa.ddl=update`
   does not rename, it would create an empty `user` table and leave every
   account in an orphaned `registry_user` that nothing reads.
2. Deploy. `jpa.ddl=update` adds the verification columns on boot.
3. `k8s/migrations/004-signup.sql` — **after** the deploy, since the column has
   to exist first. A `NOT NULL` column added to a table with rows takes MySQL's
   implicit default, so existing accounts come back `emailVerified = 0` and are
   locked out until this single `UPDATE` runs. A no-op where nobody has an
   account yet.

Rotating the signing key invalidates every outstanding token immediately:

```bash
kubectl patch secret amlang-api-secret --type=merge \
  -p '{"stringData":{"auth-secret":"'"$(openssl rand -hex 32)"'"}}'
kubectl rollout restart deployment/amlang-api
```

A value left as the literal `${AUTH_SECRET}` — what Play leaves behind when the
env var is missing — is treated as unset rather than used as a key, so a
misconfigured deployment fails closed instead of signing with a string that is
public in the repository.

## Endpoints

| | |
|---|---|
| `POST /users/signup` | public; creates an unverified account and mails a code |
| `POST /users/verify` | public; code -> verified account and an access token |
| `POST /users/verify/resend` | public; mails a fresh code, replacing the old one |
| `POST /users/login` | public; returns an access token and a refresh token |
| `POST /users/token/refresh` | public; spends a refresh token for a new pair |
| `POST /users/logout` | public; ends the session that refresh token belongs to |
| `GET /users/me/sessions` | your signed-in machines |
| `DELETE /users/me/sessions/{id}` | sign one of them out |
| `DELETE /users/me/sessions` | sign all of them out |
| `GET /users/me` | any authenticated caller |
| `GET /users/me/extensions` | any authenticated caller; what they participate in |
| `GET /users/me/extensions/{id}[/v[/{version}[/{platform}/r/{name}]]]` | an accepted member; their own entry, versions and files, unapproved included |
| `GET /admin/users` | Administrator |
| `POST /admin/users` | Administrator; create or update |
| `GET /admin/users/{username}` | Administrator |
| `DELETE /admin/users/{username}` | Administrator; also removes their memberships |
| `GET /admin/users/{username}/extensions` | Administrator; what they participate in |
| `GET /admin/users/{username}/sessions` | Administrator; their signed-in machines |
| `DELETE /admin/users/{username}/sessions` | Administrator; end all of them |
