How to set up device based auth via code based linking
Let a device, CLI or TV app with no easy browser call Port OS as a user. Your client asks Port OS for a short code. The user types the code on a Port OS web page from any other device and approves. Your client polls until the approval arrives, then receives tokens. You need a registered OAuth client that is allowed to use the device code grant first; see Set up an OAuth client for Port OS.
Every request in this flow carries a DPoP proof, and the tokens are bound to the key that signed it. Keep that key for as long as you keep the tokens.
Connection details
| Setting | Value |
|---|---|
| Device authorization endpoint | https://auth.portoperatingsystem.com/auth/device-authorization |
| Token endpoint | https://auth.portoperatingsystem.com/auth/token |
| Grant type | urn:ietf:params:oauth:grant-type:device_code |
| Proof of possession | A DPoP header on every request |
| Code lifetime | 30 minutes (expires_in is 1800) |
| Polling interval | 5 seconds (interval) |
The client must have the device code grant registered. The OAuth clients page on the website creates authorization code clients only, so create a device client with the create client operation and put urn:ietf:params:oauth:grant-type:device_code in grantTypes. Anonymous dynamic registration clients cannot use this flow.
1. Create a DPoP key
Create an asymmetric key pair once and keep the private key on the device. Port OS accepts ES256, ES384, ES512, RS256, RS384, RS512 and EdDSA proofs. This guide uses ES256.
Each proof is a signed JWT whose header carries your public key as jwk. Its claims name the request:
| Claim | Value |
|---|---|
htm | The HTTP method, such as POST |
htu | The full request URL, without query or fragment |
iat | The current time in seconds. It must be within 60 seconds of the server clock |
jti | A new random value for every proof. A repeated jti is rejected |
const b64u = (input) => {
const bytes = typeof input === 'string' ? new TextEncoder().encode(input) : new Uint8Array(input);
return btoa(String.fromCharCode(...bytes)).replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/, '');
};
const keys = await crypto.subtle.generateKey(
{ name: 'ECDSA', namedCurve: 'P-256' }, false, ['sign', 'verify']);
const jwk = await crypto.subtle.exportKey('jwk', keys.publicKey);
async function dpopProof(method, url) {
const header = { typ: 'dpop+jwt', alg: 'ES256', jwk: { kty: jwk.kty, crv: jwk.crv, x: jwk.x, y: jwk.y } };
const claims = { htm: method, htu: url, iat: Math.floor(Date.now() / 1000), jti: crypto.randomUUID() };
const signingInput = b64u(JSON.stringify(header)) + '.' + b64u(JSON.stringify(claims));
const signature = await crypto.subtle.sign(
{ name: 'ECDSA', hash: 'SHA-256' }, keys.privateKey, new TextEncoder().encode(signingInput));
return signingInput + '.' + b64u(signature);
}Build a new proof for every request. Never reuse one.
2. Ask for a device code
POST a form with your client_id and optionally a space-separated scope.
curl -X POST https://auth.portoperatingsystem.com/auth/device-authorization \
-H "Content-Type: application/x-www-form-urlencoded" \
-H "DPoP: $DPOP_PROOF" \
--data-urlencode "client_id=YOUR_CLIENT_ID" \
--data-urlencode "scope=endpoint:read message:send"const deviceUrl = 'https://auth.portoperatingsystem.com/auth/device-authorization';
const deviceResponse = await fetch(deviceUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
DPoP: await dpopProof('POST', deviceUrl),
},
body: new URLSearchParams({
client_id: 'YOUR_CLIENT_ID',
scope: 'endpoint:read message:send',
}),
});
const device = await deviceResponse.json();The response:
{
"device_code": "DEVICE_CODE_KEEP_PRIVATE",
"user_code": "ABCD-EFGH",
"verification_uri": "https://verification.example/oauth/device/verify",
"verification_uri_complete": "https://verification.example/oauth/device/verify?user_code=ABCD-EFGH",
"expires_in": 1800,
"interval": 5
}Show the user the user_code and the verification address, or render verification_uri_complete as a QR code. Never show the device_code.
Known issue: the service currently returns a
verification_urion a retired host, not onportoperatingsystem.com. The Port OS verification page is/oauth/device/verifyonportoperatingsystem.com. Until the service is corrected, tell users to open https://portoperatingsystem.com/oauth/device/verify and type the code there.
3. The user approves
On the verification page the user signs in to Port OS, enters the code, reviews the permissions your client asked for and clicks Allow. They can uncheck permissions, so the grant may carry fewer scopes than you asked for.
4. Poll for the tokens
Until the user answers, poll the token endpoint no faster than interval seconds. Sign a new proof for each poll.
curl -X POST https://auth.portoperatingsystem.com/auth/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=YOUR_CLIENT_ID"const tokenUrl = 'https://auth.portoperatingsystem.com/auth/token';
const sleep = (seconds) => new Promise((resolve) => setTimeout(resolve, seconds * 1000));
let interval = device.interval;
const deadline = Date.now() + device.expires_in * 1000;
let tokens;
while (Date.now() < deadline) {
await sleep(interval);
const poll = await fetch(tokenUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
DPoP: await dpopProof('POST', tokenUrl),
},
body: new URLSearchParams({
grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
device_code: device.device_code,
client_id: 'YOUR_CLIENT_ID',
}),
});
const body = await poll.json();
if (poll.ok) { tokens = body; break; }
if (body.error === 'authorization_pending') continue;
if (body.error === 'slow_down') { interval += 5; continue; }
throw new Error('Device login stopped: ' + body.error); // access_denied or expired_token
}When the user approves, the response carries the tokens:
{
"access_token": "ACCESS_TOKEN_STORE_PRIVATELY",
"refresh_token": "REFRESH_TOKEN_STORE_PRIVATELY",
"token_type": "DPoP",
"expires_in": 86400,
"scope": "endpoint:read message:send"
}The access token lasts 24 hours and the refresh token 30 days. Both are bound to your DPoP key.
5. Call Port OS
Send the access token with the DPoP scheme and a fresh proof for the request, with the request's own method and URL.
curl https://api.portoperatingsystem.com/endpoints \
-H "Authorization: DPoP ACCESS_TOKEN" \
-H "DPoP: $DPOP_PROOF_FOR_THIS_REQUEST"6. Refresh the tokens
Refresh with the same key. A proof from a different key is rejected.
const refreshed = await fetch(tokenUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
DPoP: await dpopProof('POST', tokenUrl),
},
body: new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: tokens.refresh_token,
client_id: 'YOUR_CLIENT_ID',
}),
});
const next = await refreshed.json(); // Replace the stored refresh token.Refresh tokens rotate, so store the new refresh_token. When the refresh token expires or is revoked, run the whole flow again from step 2.
If something goes wrong
| Problem | What to do |
|---|---|
authorization_pending | The user has not answered yet. Keep polling at the interval. |
slow_down | You polled too fast. Add 5 seconds to your interval. |
access_denied | The user clicked Deny. Stop and tell them. |
expired_token | The code expired after 30 minutes. Request a new one in step 2. |
invalid_client or unauthorized_client | The client ID is wrong or the client does not have the device code grant. Create a device client as described above. |
invalid_dpop_proof or a 400 about the proof | Check htm and htu match the request exactly, iat is within 60 seconds, jti is new, and the public key is in the header as jwk. |
| Refresh rejected | Sign the proof with the same key that was used to get the tokens. |