Auth
Invite-gated login, passkeys, cookies, token refresh, logout, and device approval. A user must hold an active invitation before login can start.
Route inventory
| Method | Route | Purpose |
|---|---|---|
POST | /v1/auth/device/start | Start invite-gated device login. (alias: /v1/auth/login/start) |
GET | /v1/auth/magic/consume | Render magic-link confirmation. Does not consume the token. |
POST | /v1/auth/magic/consume | Consume the token and approve the device login. |
POST | /v1/auth/device/poll | Poll until approval returns a token set. |
POST | /v1/auth/token/refresh | Rotate access/refresh credentials. |
POST | /v1/auth/passkeys/… | WebAuthn register & login (start / finish). |
POST | /v1/auth/logout | Clear browser auth cookies. |
GET·POST | /device | Check & approve a pending device code as the browser user. |
Start device login
Begin the device authorization grant. Returns a short userCode for the human to approve plus a deviceCode the client polls with.
Body
| Field | Required | Notes |
|---|---|---|
email | required | Invited account email. Must exist in the org directory. |
deviceId | required | Stable, client-chosen, revocable device id. Reuse on refresh. |
deviceName | optional | Human label shown in the device list. Defaults to hostname. |
curl -X POST https://api.s46.dev/v1/auth/device/start \
-H "Content-Type: application/json" \
-d '{
"email": "dscape@s46.dev",
"deviceId": "dev-laptop",
"deviceName": "Dev laptop"
}'const start = await fetch("https://api.s46.dev/v1/auth/device/start", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
email: "dscape@s46.dev",
deviceId: "dev-laptop",
deviceName: "Dev laptop",
}),
}).then((r) => r.json());import requests
start = requests.post("https://api.s46.dev/v1/auth/device/start", json={
"email": "dscape@s46.dev",
"deviceId": "dev-laptop",
"deviceName": "Dev laptop",
}).json(){
"deviceCode": "s46_device_...",
"userCode": "WXYZ-1234",
"verificationUri": "https://api.s46.dev/v1/auth/magic/consume?token=...",
"intervalSeconds": 2,
"expiresAt": "2030-01-01T12:00:00Z"
}Errors
403 | not_invited | Email is not invited or invite was revoked. |
400 | invalid_request | Missing or malformed field. |
503 | email_unavailable | Magic-link email delivery is not configured or failed. |
Approve the login
The GET renders the confirmation page and validates but does not consume the token, so email scanners can't approve a login. The POST consumes the token, creates a browser session cookie, and approves the pending device login.
# JSON clients
curl -X POST https://api.s46.dev/v1/auth/magic/consume \
-H "Content-Type: application/json" \
-d '{ "token": "..." }'
# Browser forms post application/x-www-form-urlencoded: token=...await fetch("https://api.s46.dev/v1/auth/magic/consume", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token: "..." }),
});requests.post("https://api.s46.dev/v1/auth/magic/consume",
json={"token": "..."})Errors
401 | unauthorized | Link reused or session invalid. |
410 | expired | Magic link expired. |
Poll for credentials
Exchange a deviceCode for a token set once the user approves. Respect intervalSeconds; polling faster earns 429 rate_limited. Send X-S46-Use-Cookies: true to receive HTTP-only cookies instead (the JSON then redacts the token values).
curl -X POST https://api.s46.dev/v1/auth/device/poll \
-H "Content-Type: application/json" \
-d '{ "deviceCode": "s46_device_..." }'async function poll(deviceCode, intervalSeconds) {
for (;;) {
const res = await fetch("https://api.s46.dev/v1/auth/device/poll", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ deviceCode }),
});
if (res.status === 200) return res.json(); // token set
if (res.status !== 428) throw new Error(await res.text());
await new Promise((r) => setTimeout(r, intervalSeconds * 1000));
}
}import time
while True:
res = requests.post("https://api.s46.dev/v1/auth/device/poll",
json={"deviceCode": start["deviceCode"]})
if res.status_code == 200:
tokens = res.json()
break
if res.status_code != 428:
res.raise_for_status()
time.sleep(start["intervalSeconds"]){ "error": { "code": "authorization_pending", "message": "authorization pending" } }{
"account": "dscape@s46.dev",
"organization": "s46",
"team": "@s46/engineering",
"deviceId": "dev-laptop",
"accessToken": "s46_access_...",
"refreshToken": "s46_refresh_...",
"expiresAt": "2030-01-01T13:00:00Z"
}Refresh
Rotate the access token. Refresh tokens are device-bound and may themselves rotate — replace the stored refresh token whenever a new one is returned. Browser clients may send X-S46-Use-Cookies: true and omit the body; the API reads the refresh token from the s46_refresh cookie.
curl -X POST https://api.s46.dev/v1/auth/token/refresh \
-H "Content-Type: application/json" \
-d '{
"account": "dscape@s46.dev",
"refreshToken": "s46_refresh_..."
}'const next = await fetch("https://api.s46.dev/v1/auth/token/refresh", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
account: "dscape@s46.dev",
refreshToken: stored.refreshToken,
}),
}).then((r) => r.json());
store(next.refreshToken); // always persist the new onenxt = requests.post("https://api.s46.dev/v1/auth/token/refresh", json={
"account": "dscape@s46.dev",
"refreshToken": stored["refreshToken"],
}).json()
store(nxt["refreshToken"]) # always persist the new one{
"account": "dscape@s46.dev",
"team": "@s46/engineering",
"deviceId": "dev-laptop",
"accessToken": "s46_access_...",
"refreshToken": "s46_refresh_...",
"expiresAt": "2030-01-01T14:00:00Z"
}Errors
401 | unauthorized | Refresh token missing, invalid, or revoked. |
410 | expired | Refresh token expired. |
Authenticated device approval
GET /device reports whether the current browser session (or bearer token) can approve a device code. POST /device approves a pending device for the authenticated user.
# Approve a pending device
curl -X POST https://api.s46.dev/device \
-H "Authorization: Bearer $S46_ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "userCode": "WXYZ-1234" }'await fetch("https://api.s46.dev/device", {
method: "POST",
headers: {
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ userCode: "WXYZ-1234" }),
});requests.post("https://api.s46.dev/device",
headers={"Authorization": f"Bearer {access_token}"},
json={"userCode": "WXYZ-1234"}){
"authenticated": true,
"user": {
"email": "dscape@s46.dev",
"organization": "s46",
"team": "@s46/engineering",
"role": "member"
}
}{
"approved": true,
"user": { "email": "dscape@s46.dev", "team": "@s46/engineering" }
}Errors
401 | authenticate_first | No authenticated session; the response carries an auth object pointing at the login routes. |
Passkeys
WebAuthn registration requires an authenticated bearer token or auth cookie; start returns creation options, finish takes the browser credential. Passkey login is email-scoped: start returns request options and finish returns the same token-set shape as device polling.
# Begin passkey login for an email
curl -X POST https://api.s46.dev/v1/auth/passkeys/login/start \
-H "Content-Type: application/json" \
-d '{ "email": "dscape@s46.dev" }'const options = await fetch("https://api.s46.dev/v1/auth/passkeys/login/start", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: "dscape@s46.dev" }),
}).then((r) => r.json());
// pass options to navigator.credentials.get(), then POST to /login/finishoptions = requests.post("https://api.s46.dev/v1/auth/passkeys/login/start",
json={"email": "dscape@s46.dev"}).json()Logout
Clears browser auth cookies. Bearer-token clients usually revoke a device instead of calling logout.
curl -X POST https://api.s46.dev/v1/auth/logout \
-H "Authorization: Bearer $S46_ACCESS_TOKEN"await fetch("https://api.s46.dev/v1/auth/logout", {
method: "POST",
headers: { Authorization: `Bearer ${accessToken}` },
});requests.post("https://api.s46.dev/v1/auth/logout",
headers={"Authorization": f"Bearer {access_token}"}){ "ok": true }