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

  1. The device creates an ES256 (P-256) key pair and keeps it. Every request it makes carries a DPoP header: a short JWT signed with that key.
  2. The device calls POST /api/oauth/device_authorization and shows the user_code (for example WDJB-MJHT), or a QR code of verification_uri_complete.
  3. 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.
  4. 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.
  5. The device calls the API with Authorization: DPoP <token> plus a new DPoP proof 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: typ is dpop+jwt, alg is ES256, and jwk is the device's public key as an EC P-256 JWK (kty, crv, x, y; never the private d).
  • The signature verifies against that key.
  • iat is the current time in seconds, within 60 seconds of the server's clock. Keep the device clock synced.
  • jti is a unique string, such as a UUID. On API calls a jti can be used once; a replayed proof is rejected.
  • htm is the request method (GET, POST, …).
  • htu is the URL you called, scheme + host + path, with no query string or fragment: https://abyrgverslun.is/api/v1/employees for a call to https://abyrgverslun.is/api/v1/employees?limit=20.
  • ath on API calls: the base64url-encoded SHA-256 hash of the access token in the Authorization header. A proof whose ath doesn't match the token is rejected. Leave ath out 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

POST/api/oauth/device_authorization

Creates a pending link request bound to the key in the DPoP proof. The body is JSON. The request expires after 10 minutes.

Headers

Content-TypestringRequired

application/json

DPoPstringRequired

A DPoP proof with htm POST and htu https://abyrgverslun.is/api/oauth/device_authorization (no ath)

Request Body

client_idstringRequired

Always "abyrg_device". Any other value gets 401 invalid_client.

device_idstringOptional

Your own hardware identifier for the device

device_namestringOptional

Default name the admin sees when approving; the admin can change it

scopestringOptional

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.

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.

POST/api/oauth/token

Exchanges an approved device code for tokens. A device code can be exchanged once.

Headers

Content-TypestringRequired

application/x-www-form-urlencoded

DPoPstringRequired

A DPoP proof with htm POST and htu https://abyrgverslun.is/api/oauth/token, signed with the same key as in Step 1

Request Body

grant_typestringRequired

urn:ietf:params:oauth:grant-type:device_code

device_codestringRequired

The device_code from Step 1

client_idstringRequired

abyrg_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. Wait interval seconds 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_id is not abyrg_device.
  • 401 invalid_dpop_proof: the proof failed verification (400 if the DPoP header 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.

POST/api/oauth/token

Rotates the refresh token and issues a new access token with the same scopes.

Headers

Content-TypestringRequired

application/x-www-form-urlencoded

DPoPstringRequired

A DPoP proof for POST https://abyrgverslun.is/api/oauth/token, signed with the device's key

Request Body

grant_typestringRequired

refresh_token

refresh_tokenstringRequired

The 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_token is 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: Bearer is rejected with 401.
  • Any DPoP failure (bad, stale or replayed proof, wrong key, wrong ath, expired token, disabled device) gets 401. A token without the endpoint's scope gets 403.
  • On a 401, refresh once and retry with a new proof. If the refresh fails with invalid_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? }