Skip to content

Commit

Permalink
feat: add abort control over Device Flow Handle polling (#357)
Browse files Browse the repository at this point in the history
Example using AbortController.

```js
const ac = new AbortController()

setTimeout(() => ac.abort(), 10000)

try {
  await handle.poll({ signal: ac.signal })
} catch (err) {
  console.log(err)
}
```

Example using handle.abort().

```js
setTimeout(() => handle.abort(), 10000)

try {
  await handle.poll({ signal: ac.signal })
} catch (err) {
  console.log(err)
}
```

resolves #355
closes #356
  • Loading branch information
panva authored Apr 22, 2021
1 parent 3a143ec commit f6faa68
Show file tree
Hide file tree
Showing 3 changed files with 125 additions and 4 deletions.
14 changes: 12 additions & 2 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -793,7 +793,8 @@ first place.

<!-- TOC DeviceFlowHandle START -->
- [Class: &lt;DeviceFlowHandle&gt;](#class-deviceflowhandle)
- [handle.poll()](#handlepoll)
- [handle.poll([options])](#handlepolloptions)
- [handle.abort()](#handleabort)
- [handle.user_code](#handleuser_code)
- [handle.verification_uri](#handleverification_uri)
- [handle.verification_uri_complete](#handleverification_uri_complete)
Expand All @@ -812,16 +813,25 @@ other defined response properties. A handle is instantiated by calling

---

#### `handle.poll()`
#### `handle.poll([options])`

This will continuously poll the token_endpoint and resolve with a TokenSet once one is received.
This will handle the defined `authorization_pending` and `slow_down` "soft" errors and continue
polling but upon any other error it will reject.

- `options`: `<Object>`
- `signal`: `<AbortSignal>` An optional AbortSignal that can be used to abort polling. When
if the signal is aborted the next interval in the poll will make the returned promise reject.
- Returns: `Promise<TokenSet>`

---

#### `handle.abort()`

This will abort ongoing polling. The next interval in the poll will result in a rejection.

---

#### `handle.user_code`

Returns the `user_code` Device Authorization Response parameter.
Expand Down
12 changes: 10 additions & 2 deletions lib/device_flow_handle.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,15 @@ class DeviceFlowHandle {
instance(this).interval = response.interval * 1000 || 5000;
}

async poll() {
abort() {
instance(this).aborted = true;
}

async poll({ signal } = {}) {
if ((signal && signal.aborted) || instance(this).aborted) {
throw new RPError('polling aborted');
}

if (this.expired()) {
throw new RPError('the device code %j has expired and the device authorization session has concluded', this.device_code);
}
Expand Down Expand Up @@ -61,7 +69,7 @@ class DeviceFlowHandle {
case 'slow_down':
instance(this).interval += 5000;
case 'authorization_pending': // eslint-disable-line no-fallthrough
return this.poll();
return this.poll({ signal });
default:
throw err;
}
Expand Down
103 changes: 103 additions & 0 deletions test/client/device_flow.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -314,6 +314,109 @@ describe('Device Flow features', () => {
});
});

it('aborts polling through DeviceFlowHandle.prototype.abort() (immediate)', () => {
const handle = new DeviceFlowHandle({
client: this.client,
response: {
verification_uri: 'https://op.example.com/device',
user_code: 'AAAA-AAAA',
device_code: 'foobar',
interval: 5,
expires_in: 300,
},
});

handle.abort();

return handle.poll().then(fail, (err) => {
expect(err.name).to.equal('RPError');
expect(err.message).to.equal('polling aborted');
});
});

it('aborts polling through DeviceFlowHandle.prototype.abort() (mid polling)', function () {
this.timeout(6000);

const handle = new DeviceFlowHandle({
client: this.client,
response: {
verification_uri: 'https://op.example.com/device',
user_code: 'AAAA-AAAA',
device_code: 'foobar',
interval: 5,
expires_in: 300,
},
});

nock('https://op.example.com')
.post('/token', () => {
handle.abort();
return true;
})
.reply(400, { error: 'authorization_pending' });

return handle.poll().then(fail, (err) => {
expect(err.name).to.equal('RPError');
expect(err.message).to.equal('polling aborted');
expect(nock.isDone()).to.be.true;
});
});

if (typeof AbortController !== 'undefined') {
it('aborts polling through AbortController (immediate)', () => {
const handle = new DeviceFlowHandle({
client: this.client,
response: {
verification_uri: 'https://op.example.com/device',
user_code: 'AAAA-AAAA',
device_code: 'foobar',
interval: 5,
expires_in: 300,
},
});

// eslint-disable-next-line no-undef
const ac = new AbortController();
ac.abort();

return handle.poll({ signal: ac.signal }).then(fail, (err) => {
expect(err.name).to.equal('RPError');
expect(err.message).to.equal('polling aborted');
});
});

it('aborts polling through AbortController (mid polling)', function () {
this.timeout(6000);

const handle = new DeviceFlowHandle({
client: this.client,
response: {
verification_uri: 'https://op.example.com/device',
user_code: 'AAAA-AAAA',
device_code: 'foobar',
interval: 5,
expires_in: 300,
},
});

// eslint-disable-next-line no-undef
const ac = new AbortController();

nock('https://op.example.com')
.post('/token', () => {
ac.abort();
return true;
})
.reply(400, { error: 'authorization_pending' });

return handle.poll({ signal: ac.signal }).then(fail, (err) => {
expect(err.name).to.equal('RPError');
expect(err.message).to.equal('polling aborted');
expect(nock.isDone()).to.be.true;
});
});
}

it('the handle tracks expiration of the device code', () => {
const handle = new DeviceFlowHandle({
response: {
Expand Down

0 comments on commit f6faa68

Please sign in to comment.