Device authentication
Link a scanner, tablet or till app to your tenant with the OAuth 2.0 device flow (RFC 8628), and prove every request comes from that device with DPoP (RFC 9449).
How it works
- The device creates an ES256 (P-256) key pair and keeps it. Every request it makes carries a
DPoPheader: a short JWT signed with that key. - The device calls
POST /api/oauth/device_authorizationand shows theuser_code(for exampleWDJB-MJHT), or a QR code ofverification_uri_complete. - A tenant admin opens Tæki (Devices) in the dashboard, clicks “Tengja tæki” (Link device), enters the code, names the device, picks its locations and approves it.
- Meanwhile the device polls
POST /api/oauth/token. After approval it gets an access token (5 minutes) and a refresh token (60 days), both bound to its key. - The device calls the API with
Authorization: DPoP <token>plus a newDPoPproof for each request, and refreshes when the access token expires.
DPoP proofs
A DPoP proof is a JWT sent in the DPoP request header. Build a new one for every request. The server checks:
- Header:
typisdpop+jwt,algisES256, andjwkis the device's public key as an EC P-256 JWK (kty,crv,x,y; never the privated). - The signature verifies against that key.
iatis the current time in seconds, within 60 seconds of the server's clock. Keep the device clock synced.jtiis a unique string, such as a UUID. On API calls ajtican be used once; a replayed proof is rejected.htmis the request method (GET,POST, …).htuis the URL you called, scheme + host + path, with no query string or fragment:https://abyrgverslun.is/api/v1/employeesfor a call tohttps://abyrgverslun.is/api/v1/employees?limit=20.athon API calls: the base64url-encoded SHA-256 hash of the access token in theAuthorizationheader. A proof whoseathdoesn't match the token is rejected. Leaveathout of proofs for the OAuth endpoints, which have no access token.- The key is the one the token (or device code, or refresh token) is bound to. Use the same key pair for the whole life of the device link. If the key is lost, link the device again.
A decoded proof for a verification call:
// Header
{
"typ": "dpop+jwt",
"alg": "ES256",
"jwk": {
"kty": "EC",
"crv": "P-256",
"x": "l8tFrhx-34tV3hRICRDY9zCkDlpBhF42UQUfWVAWBFs",
"y": "9VE4jf_Ok_o64zbTTlcuNJajHmt6v9TDVrU0CdvGRDA"
}
}
// Payload
{
"jti": "0b6f6f0e-4c1c-4d1e-9d8a-3c2f9b7e1a55",
"htm": "POST",
"htu": "https://abyrgverslun.is/api/v1/verifications",
"iat": 1790676900,
"ath": "fUHyO2r2Z3DZ53EsNrWBb0xWXoaNy59IiKCAqksmQEo"
}Step 1 — Start the device flow
/api/oauth/device_authorizationCreates a pending link request bound to the key in the DPoP proof. The body is JSON. The request expires after 10 minutes.
Headers
| Name | Type | Required | Description |
|---|---|---|---|
| Content-Type | string | Required | application/json |
| DPoP | string | Required | A DPoP proof with htm POST and htu https://abyrgverslun.is/api/oauth/device_authorization (no ath) |
Content-TypestringRequiredapplication/json
DPoPstringRequiredA DPoP proof with htm POST and htu https://abyrgverslun.is/api/oauth/device_authorization (no ath)
Request Body
| Name | Type | Required | Description |
|---|---|---|---|
| client_id | string | Required | Always "abyrg_device". Any other value gets 401 invalid_client. |
| device_id | string | Optional | Your own hardware identifier for the device |
| device_name | string | Optional | Default name the admin sees when approving; the admin can change it |
| scope | string | Optional | Space-separated scopes. A device may hold only the store scope set, devices:read employees:read transactions:write, which is also the default. A subset of it is accepted; any other scope, wildcards included, gets 400 invalid_scope. The approving admin sees the requested scopes. |
client_idstringRequiredAlways "abyrg_device". Any other value gets 401 invalid_client.
device_idstringOptionalYour own hardware identifier for the device
device_namestringOptionalDefault name the admin sees when approving; the admin can change it
scopestringOptionalSpace-separated scopes. A device may hold only the store scope set, devices:read employees:read transactions:write, which is also the default. A subset of it is accepted; any other scope, wildcards included, gets 400 invalid_scope. The approving admin sees the requested scopes.
Examples
cURL
curl -s -X POST https://abyrgverslun.is/api/oauth/device_authorization \
-H "Content-Type: application/json" \
-H "DPoP: $DPOP_PROOF" \
-d '{"client_id": "abyrg_device", "device_name": "Till 1"}' | jq .Response
{
"device_code": "dc_4f1c9a7e2b6d4e0f8a3c5b7d9e1f2a4c",
"user_code": "WDJB-MJHT",
"verification_uri": "https://abyrgverslun.is/tenant/devices/link",
"verification_uri_complete": "https://abyrgverslun.is/tenant/devices/link?code=WDJB-MJHT",
"expires_in": 600,
"interval": 5
}Keep device_code secret on the device. Show user_code on screen, or a QR code of verification_uri_complete, which opens the dashboard's “Tengja tæki” (Link device) drawer on Tæki (Devices) with the code filled in.
Errors: 400 invalid_dpop_proof (missing or bad proof), 400 invalid_request (body not JSON or fields of the wrong type), 400 invalid_scope (a scope outside the store scope set), 401 invalid_client. See Errors.
Step 2 — An admin approves the device
A dashboard user with the devices:write permission (the admin role by default) opens Tæki (Devices), clicks “Tengja tæki” (Link device), enters the code, and approves or denies the request. The verification_uri, /tenant/devices/link, still works and redirects there. On approval they set the device name and the locations it works at. The device then appears in the dashboard's device list.
Codes are not case-sensitive. The request must be approved within the 10 minutes of expires_in.
Step 3 — Poll for tokens
While the admin approves, poll the token endpoint every interval seconds (5). The body is form-encoded, as for every token request.
/api/oauth/tokenExchanges an approved device code for tokens. A device code can be exchanged once.
Headers
| Name | Type | Required | Description |
|---|---|---|---|
| Content-Type | string | Required | application/x-www-form-urlencoded |
| DPoP | string | Required | A DPoP proof with htm POST and htu https://abyrgverslun.is/api/oauth/token, signed with the same key as in Step 1 |
Content-TypestringRequiredapplication/x-www-form-urlencoded
DPoPstringRequiredA DPoP proof with htm POST and htu https://abyrgverslun.is/api/oauth/token, signed with the same key as in Step 1
Request Body
| Name | Type | Required | Description |
|---|---|---|---|
| grant_type | string | Required | urn:ietf:params:oauth:grant-type:device_code |
| device_code | string | Required | The device_code from Step 1 |
| client_id | string | Required | abyrg_device |
grant_typestringRequiredurn:ietf:params:oauth:grant-type:device_code
device_codestringRequiredThe device_code from Step 1
client_idstringRequiredabyrg_device
Examples
cURL
curl -s -X POST https://abyrgverslun.is/api/oauth/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-H "DPoP: $DPOP_PROOF" \
--data-urlencode "grant_type=urn:ietf:params:oauth:grant-type:device_code" \
--data-urlencode "device_code=$DEVICE_CODE" \
--data-urlencode "client_id=abyrg_device" | jq .Response while waiting (400)
{
"error": "authorization_pending",
"interval": 5
}Response
{
"access_token": "eyJhbGciOiJFUzI1NiIsInR5cCI6ImF0K2p3dCJ9...",
"token_type": "DPoP",
"expires_in": 300,
"refresh_token": "rt_device_9c2e4a6b8d0f1e3c5a7b9d1f3e5c7a9b1d3f5e7c9a1b3d5f7e9c1a3b5d7f9e1c",
"scope": "devices:read employees:read transactions:write"
}Poll responses (errors have error and no error_description):
400 authorization_pending: not approved yet. Waitintervalseconds and poll again.400 expired_token: the 10 minutes ran out. Start again at Step 1.400 access_denied: the admin denied the request.401 invalid_grant: unknown or already-used device code.401 invalid_client:client_idis notabyrg_device.401 invalid_dpop_proof: the proof failed verification (400if theDPoPheader is missing).401 invalid_dpop_binding: the proof is signed with a different key from the one used in Step 1.
The server does not send slow_down to devices, but polling faster than interval gains nothing.
Step 4 — Refresh the access token
Access tokens last 5 minutes. Refresh tokens start with rt_device_ and last 60 days. Each refresh returns a new refresh token, valid for another 60 days, and the old one stops working. Store the new one before you use the new access token.
/api/oauth/tokenRotates the refresh token and issues a new access token with the same scopes.
Headers
| Name | Type | Required | Description |
|---|---|---|---|
| Content-Type | string | Required | application/x-www-form-urlencoded |
| DPoP | string | Required | A DPoP proof for POST https://abyrgverslun.is/api/oauth/token, signed with the device's key |
Content-TypestringRequiredapplication/x-www-form-urlencoded
DPoPstringRequiredA DPoP proof for POST https://abyrgverslun.is/api/oauth/token, signed with the device's key
Request Body
| Name | Type | Required | Description |
|---|---|---|---|
| grant_type | string | Required | refresh_token |
| refresh_token | string | Required | The current refresh token (rt_device_…) |
grant_typestringRequiredrefresh_token
refresh_tokenstringRequiredThe current refresh token (rt_device_…)
Examples
cURL
curl -s -X POST https://abyrgverslun.is/api/oauth/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-H "DPoP: $DPOP_PROOF" \
--data-urlencode "grant_type=refresh_token" \
--data-urlencode "refresh_token=$REFRESH_TOKEN" | jq .Response
{
"access_token": "eyJhbGciOiJFUzI1NiIsInR5cCI6ImF0K2p3dCJ9...",
"token_type": "DPoP",
"expires_in": 300,
"refresh_token": "rt_device_1d3f5e7c9a1b3d5f7e9c1a3b5d7f9e1c9c2e4a6b8d0f1e3c5a7b9d1f3e5c7a9b",
"scope": "devices:read employees:read transactions:write"
}Refresh errors:
400 invalid_grant: the refresh token is unknown, already used, expired, or the device has been disabled or revoked. Link the device again.400 invalid_dpop_proof: the proof is missing or failed verification.400 invalid_dpop_binding: the proof is signed with a different key from the one the refresh token is bound to.400 invalid_request:refresh_tokenis missing.
Step 5 — Call the API
Send the access token with the DPoP scheme and a fresh proof that includes ath:
GET /api/v1/devices/me HTTP/1.1
Host: abyrgverslun.is
Authorization: DPoP eyJhbGciOiJFUzI1NiIsInR5cCI6ImF0K2p3dCJ9...
DPoP: eyJ0eXAiOiJkcG9wK2p3dCIsImFsZyI6IkVTMjU2IiwiandrIjp7...- A device token sent as
Authorization: Beareris rejected with401. - Any DPoP failure (bad, stale or replayed proof, wrong key, wrong
ath, expired token, disabled device) gets401. A token without the endpoint's scope gets403. - On a
401, refresh once and retry with a new proof. If the refresh fails withinvalid_grant, the device has to be linked again.
The endpoints a device can call are listed in the Device API.
Full example (JavaScript)
Creates a key, links the device, polls for tokens, refreshes on 401, and runs a pass verification. It uses only fetch and WebCrypto, so it runs in Node 20+ and in browsers.
// Runs in Node 20+ and modern browsers (global fetch + WebCrypto).
const BASE = "https://abyrgverslun.is";
const CLIENT_ID = "abyrg_device";
// --- base64url helpers -------------------------------------------------
const enc = new TextEncoder();
function b64url(bytes) {
let s = "";
for (const b of new Uint8Array(bytes)) s += String.fromCharCode(b);
return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}
const b64urlJson = (obj) => b64url(enc.encode(JSON.stringify(obj)));
// --- 1. The device's key pair -------------------------------------------
// Create it once and keep it for the life of the device link: the device
// code, the refresh token and every access token are bound to this key.
// (Persist it in secure storage; a browser can keep a non-extractable
// CryptoKey in IndexedDB.)
const keyPair = await crypto.subtle.generateKey(
{ name: "ECDSA", namedCurve: "P-256" },
false, // private key not extractable; the public key always is
["sign", "verify"],
);
const { kty, crv, x, y } = await crypto.subtle.exportKey("jwk", keyPair.publicKey);
const publicJwk = { kty, crv, x, y };
// --- 2. A DPoP proof: a fresh one for every request ----------------------
async function dpopProof(htm, url, accessToken) {
const u = new URL(url);
const payload = {
jti: crypto.randomUUID(), // unique per proof; reuse is rejected
htm, // HTTP method, upper case
htu: u.origin + u.pathname, // no query string or fragment
iat: Math.floor(Date.now() / 1000), // seconds; must be within 60 s of server time
};
if (accessToken) {
// ath = base64url(SHA-256(access token)), on resource calls
payload.ath = b64url(await crypto.subtle.digest("SHA-256", enc.encode(accessToken)));
}
const header = { typ: "dpop+jwt", alg: "ES256", jwk: publicJwk };
const input = b64urlJson(header) + "." + b64urlJson(payload);
// WebCrypto returns the raw 64-byte r||s signature that ES256 expects.
const sig = await crypto.subtle.sign(
{ name: "ECDSA", hash: "SHA-256" },
keyPair.privateKey,
enc.encode(input),
);
return input + "." + b64url(sig);
}
// --- 3. Start the device flow -------------------------------------------
async function startDeviceAuthorization() {
const url = BASE + "/api/oauth/device_authorization";
const res = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
DPoP: await dpopProof("POST", url),
},
body: JSON.stringify({
client_id: CLIENT_ID,
device_id: "till-01", // optional: your own hardware id
device_name: "Till 1, Laugavegur", // optional: default name shown to the admin
}),
});
const body = await res.json();
if (!res.ok) throw new Error(body.error + ": " + (body.error_description ?? ""));
return body; // { device_code, user_code, verification_uri, verification_uri_complete, expires_in, interval }
}
// --- 4. Token endpoint helper (form-encoded, with a DPoP proof) --------
async function tokenRequest(params) {
const url = BASE + "/api/oauth/token";
const res = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
DPoP: await dpopProof("POST", url),
},
body: new URLSearchParams(params),
});
return { ok: res.ok, status: res.status, body: await res.json() };
}
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
// --- 5. Poll until an admin approves ------------------------------------
async function pollForTokens(auth) {
const deadline = Date.now() + auth.expires_in * 1000;
let interval = auth.interval;
while (Date.now() < deadline) {
await sleep(interval * 1000);
const { ok, body } = await tokenRequest({
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
device_code: auth.device_code,
client_id: CLIENT_ID,
});
if (ok) return body; // { access_token, token_type: "DPoP", expires_in, refresh_token, scope }
if (body.error === "authorization_pending") {
interval = body.interval ?? interval;
continue;
}
throw new Error(body.error); // expired_token, access_denied, invalid_grant, ...
}
throw new Error("expired_token");
}
// --- 6. Refresh (rotates the refresh token) -----------------------------
let tokens;
async function refresh() {
const { ok, body } = await tokenRequest({
grant_type: "refresh_token",
refresh_token: tokens.refresh_token,
});
if (!ok) throw new Error(body.error); // invalid_grant: link the device again
tokens = body; // store the NEW refresh_token right away; the old one is dead
}
// --- 7. Call the API -----------------------------------------------------
async function api(method, path, json, retried = false) {
const url = BASE + path;
const res = await fetch(url, {
method,
headers: {
Authorization: "DPoP " + tokens.access_token,
DPoP: await dpopProof(method, url, tokens.access_token),
...(json ? { "Content-Type": "application/json" } : {}),
},
body: json ? JSON.stringify(json) : undefined,
});
if (res.status === 401 && !retried) {
await refresh(); // access tokens last 5 minutes
return api(method, path, json, true);
}
return { status: res.status, body: await res.json() };
}
// --- Putting it together ------------------------------------------------
const auth = await startDeviceAuthorization();
console.log("On the dashboard, enter", auth.user_code);
console.log("or open", auth.verification_uri_complete);
tokens = await pollForTokens(auth);
const me = await api("GET", "/api/v1/devices/me");
console.log("Linked as", me.body.data.name, me.body.data.id);
const verification = await api("POST", "/api/v1/verifications", {
method: "pass",
passData: "2QWPSZMFXGAGCH1NAJB0RMH298XB106N5E2D7WTW5M2H4XEHYZZ4H",
requiredAge: 18,
});
console.log(verification.body); // { result: "pass" | "fail", transactionId, reason? }