Skip to content

Commit

Permalink
feat: add RFC8628 - OAuth 2.0 Device Authorization Grant (Device Flow…
Browse files Browse the repository at this point in the history
…) support
  • Loading branch information
panva committed Aug 24, 2019
1 parent dfdd8cb commit adb4b76
Show file tree
Hide file tree
Showing 10 changed files with 673 additions and 18 deletions.
30 changes: 29 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ openid-client.
- Client Authenticated request to token revocation
- [RFC7662 - OAuth 2.0 Token introspection][feature-introspection]
- Client Authenticated request to token introspection
- [RFC8628 - OAuth 2.0 Device Authorization Grant (Device Flow)][feature-device-flow]
- [draft-ietf-oauth-mtls - OAuth 2.0 Mutual TLS Client Authentication and Certificate-Bound Access Tokens][feature-mtls]
- Mutual TLS Client Certificate-Bound Access Tokens
- Metadata for Mutual TLS Endpoint Aliases
Expand Down Expand Up @@ -206,6 +207,32 @@ client.callback('https://client.example.com/callback', params, { nonce }) // =>
});
```

### Device Authorization Grant (Device Flow)

[RFC8628 - OAuth 2.0 Device Authorization Grant (Device Flow)](https://tools.ietf.org/html/rfc8628)
is started by starting a Device Authorization Request.

```js
const handle = await client.deviceAuthorization();
console.log('User Code: ', handle.user_code);
console.log('Verification URI: ', handle.verification_uri);
console.log('Verification URI (complete): ', handle.verification_uri_complete);
```

The handle represents a Device Authorization Response with the `verification_uri`, `user_code` and
other defined response properties.

You will display the instructions to the end-user and have him directed at `verification_uri` or
`verification_uri_complete`, afterwards you can start polling for the Device Access Token Response.
```js
const tokenSet = await handle.poll();
console.log('received tokens %j', tokenSet);
```

This will poll in the defined interval and only 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. With tokenSet received you can throw away the handle.

## Electron Support

Electron v6.x runtime is supported to the extent of the crypto engine BoringSSL feature parity with
Expand Down Expand Up @@ -249,7 +276,8 @@ See [Client Authentication Methods][documentation-methods].
[feature-registration]: https://openid.net/specs/openid-connect-registration-1_0.html
[feature-revocation]: https://tools.ietf.org/html/rfc7009
[feature-introspection]: https://tools.ietf.org/html/rfc7662
[feature-mtls]: https://tools.ietf.org/html/draft-ietf-oauth-mtls-14
[feature-mtls]: https://tools.ietf.org/html/draft-ietf-oauth-mtls-17
[feature-device-flow]: https://tools.ietf.org/html/rfc8628
[openid-certified-link]: https://openid.net/certification/
[passport-url]: http://passportjs.org
[npm-url]: https://www.npmjs.com/package/openid-client
Expand Down
104 changes: 104 additions & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
- [Client](#client)
- [Customizing](#customizing)
- [TokenSet](#tokenset)
- [DeviceFlowHandle](#deviceflowhandle)
- [Strategy](#strategy)
- [generators](#generators)
- [errors](#errors)
Expand Down Expand Up @@ -143,6 +144,7 @@ Performs [OpenID Provider Issuer Discovery][webfinger-discovery] based on End-Us
- [client.introspect(token[, tokenTypeHint[, extras]])](#clientintrospecttoken-tokentypehint-extras)
- [client.revoke(token[, tokenTypeHint[, extras]])](#clientrevoketoken-tokentypehint-extras)
- [client.requestObject(payload)](#clientrequestobjectpayload)
- [client.deviceAuthorization(parameters[, extras])](#clientdeviceauthorizationparameters-extras)
- [Client Authentication Methods](#client-authentication-methods)
- [Client.register(metadata[, other])](#clientregistermetadata-other)
- [Client.fromUri(registrationClientUri, registrationAccessToken[, jwks])](#clientfromuriregistrationclienturi-registrationaccesstoken-jwks)
Expand Down Expand Up @@ -390,6 +392,27 @@ metadata for determining the algorithms to use.

---

#### `client.deviceAuthorization(parameters[, extras])`

[RFC8628 - OAuth 2.0 Device Authorization Grant (Device Flow)](https://tools.ietf.org/html/rfc8628)

Starts a Device Authorization Request at the issuer's `device_authorization_endpoint` and returns
a handle for subsequent Device Access Token Request polling.

- `parameters`: `<Object>`
- `client_id`: `<string>` **Default:** client's client_id
- `scope`: `<string>` **Default:** 'openid'
- any Device Authorization Request parameters may also be included
- `extras`: `<Object>`
- `exchangeBody`: `<Object>` extra request body properties to be sent to the AS during the Device
Access Token Request
- `clientAssertionPayload`: `<Object>` extra client assertion payload parameters to be sent as
part of a client JWT assertion. This is only used when the client's `token_endpoint_auth_method`
is either `client_secret_jwt` or `private_key_jwt`.
- Returns: `Promise<DeviceFlowHandle>`

---

#### Client Authentication Methods

Defined in [Core 1.0][client-authentication] and [draft-ietf-oauth-mtls-14](https://tools.ietf.org/html/draft-ietf-oauth-mtls-14)
Expand Down Expand Up @@ -639,6 +662,87 @@ first place.

---

## DeviceFlowHandle

<!-- TOC DeviceFlowHandle START -->
- [Class: &lt;DeviceFlowHandle&gt;](#class-deviceflowhandle)
- [handle.poll()](#handlepoll)
- [handle.user_code](#handleuser_code)
- [handle.verification_uri](#handleverification_uri)
- [handle.verification_uri_complete](#handleverification_uri_complete)
- [handle.expired()](#handleexpired)
- [handle.expires_in](#handleexpires_in)
- [handle.device_code](#handledevice_code)
<!-- TOC DeviceFlowHandle END -->

---

#### Class: `<DeviceFlowHandle>`

The handle represents a Device Authorization Response with the `verification_uri`, `user_code` and
other defined response properties. A handle is instantiated by calling
[`client.deviceAuthorization()`](#clientdeviceauthorizationparameters-extras)

---

#### `handle.poll()`

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.

- Returns: `Promise<TokenSet>`

---

#### `handle.user_code`

Returns the `user_code` Device Authorization Response parameter.

- Returns: `<string>`

---

#### `handle.verification_uri`

Returns the `verification_uri` Device Authorization Response parameter.

- Returns: `<string>`

---

#### `handle.verification_uri_complete`

Returns the `verification_uri_complete` Device Authorization Response parameter.

- Returns: `<string>`

---

#### `handle.expired()`

Returns true/false depending on whether the handle is expired or not.

- Returns: `<boolean>`

---

#### `handle.expires_in`

Returns the number of seconds until the handle expires.

- Returns: `<number>`

---

#### `handle.device_code`

Returns the `device_code` Device Authorization Response parameter.

- Returns: `<string>`

---

## Strategy

<!-- TOC Strategy START -->
Expand Down
43 changes: 39 additions & 4 deletions lib/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,10 @@ const url = require('url');

const jose = require('@panva/jose');
const base64url = require('base64url');
const {
defaultsDeep, isPlainObject, merge, defaults, omitBy,
} = require('lodash');
const defaultsDeep = require('lodash/defaultsDeep');
const defaults = require('lodash/defaults');
const merge = require('lodash/merge');
const isPlainObject = require('lodash/isPlainObject');
const tokenHash = require('oidc-token-hash');

const { assertSigningAlgValuesSupport, assertIssuerConfiguration } = require('./helpers/assert');
Expand All @@ -28,6 +29,7 @@ const {
const issuerRegistry = require('./issuer_registry');
const instance = require('./helpers/weak_cache');
const { authenticatedPost, resolveResponseType, resolveRedirectUri } = require('./helpers/client');
const DeviceFlowHandle = require('./device_flow_handle');

function pickCb(input) {
return pick(input, ...CALLBACK_PROPERTIES);
Expand Down Expand Up @@ -1042,7 +1044,7 @@ module.exports = (issuer, aadIssValidation = false) => class Client extends Base
'token',
{
form: true,
body: omitBy(body, (arg) => arg === undefined),
body,
json: true,
},
{ clientAssertionPayload },
Expand All @@ -1052,6 +1054,39 @@ module.exports = (issuer, aadIssValidation = false) => class Client extends Base
return new TokenSet(responseBody);
}

/**
* @name deviceAuthorization
* @api public
*/
async deviceAuthorization(params = {}, { exchangeBody, clientAssertionPayload } = {}) {
assertIssuerConfiguration(this.issuer, 'device_authorization_endpoint');
assertIssuerConfiguration(this.issuer, 'token_endpoint');

const body = authorizationParams.call(this, {
redirect_uri: null, response_type: null, ...params,
});

const response = await authenticatedPost.call(
this,
'device_authorization',
{
form: true,
body,
json: true,
},
{ clientAssertionPayload, endpointAuthMethod: 'token' },
);
const responseBody = processResponse(response);

return new DeviceFlowHandle({
client: this,
exchangeBody,
clientAssertionPayload,
response: responseBody,
maxAge: params.max_age,
});
}

/**
* @name revoke
* @api public
Expand Down
115 changes: 115 additions & 0 deletions lib/device_flow_handle.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
/* eslint-disable camelcase */
const { inspect } = require('util');

const { RPError, OPError } = require('./errors');
const instance = require('./helpers/weak_cache');
const now = require('./helpers/unix_timestamp');
const { authenticatedPost } = require('./helpers/client');
const processResponse = require('./helpers/process_response');
const TokenSet = require('./token_set');

class DeviceFlowHandle {
constructor({
client, exchangeBody, clientAssertionPayload, response, maxAge,
}) {
['verification_uri', 'user_code', 'device_code'].forEach((prop) => {
if (typeof response[prop] !== 'string' || !response[prop]) {
throw new RPError(`expected ${prop} string to be returned by Device Authorization Response, got %j`, response[prop]);
}
});

if (!Number.isSafeInteger(response.expires_in)) {
throw new RPError('expected expires_in number to be returned by Device Authorization Response, got %j', response.expires_in);
}

instance(this).expires_at = now() + response.expires_in;
instance(this).client = client;
instance(this).maxAge = maxAge;
instance(this).exchangeBody = exchangeBody;
instance(this).clientAssertionPayload = clientAssertionPayload;
instance(this).response = response;
instance(this).interval = response.interval * 1000 || 5000;
}

async poll() {
if (this.expired()) {
throw new RPError('the device code %j has expired and the device authorization session has concluded', this.device_code);
}

await new Promise((resolve) => setTimeout(resolve, instance(this).interval));

const response = await authenticatedPost.call(
instance(this).client,
'token',
{
form: true,
body: {
...instance(this).exchangeBody,
grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
device_code: this.device_code,
},
json: true,
},
{ clientAssertionPayload: instance(this).clientAssertionPayload },
);

let responseBody;
try {
responseBody = processResponse(response);
} catch (err) {
switch (err instanceof OPError && err.error) {
case 'slow_down':
instance(this).interval += 5000;
case 'authorization_pending': // eslint-disable-line no-fallthrough
return this.poll();
default:
throw err;
}
}

const tokenset = new TokenSet(responseBody);

if ('id_token' in tokenset) {
await instance(this).client.decryptIdToken(tokenset);
await instance(this).client.validateIdToken(tokenset, undefined, 'token', instance(this).maxAge);
}

return tokenset;
}

get device_code() {
return instance(this).response.device_code;
}

get user_code() {
return instance(this).response.user_code;
}

get verification_uri() {
return instance(this).response.verification_uri;
}

get verification_uri_complete() {
return instance(this).response.verification_uri_complete;
}

get expires_in() {
return Math.max.apply(null, [instance(this).expires_at - now(), 0]);
}

expired() {
return this.expires_in === 0;
}

/* istanbul ignore next */
[inspect.custom]() {
return `${this.constructor.name} ${inspect(instance(this).response, {
depth: Infinity,
colors: process.stdout.isTTY,
compact: false,
sorted: true,
})}`;
}
}

module.exports = DeviceFlowHandle;
Loading

0 comments on commit adb4b76

Please sign in to comment.