diff --git a/README.md b/README.md index 07e02f7..e102670 100644 --- a/README.md +++ b/README.md @@ -175,7 +175,33 @@ authenticate(Uri uri, String clientId, List scopes) async { } ``` +### Usage example flutter - device code flow +```dart + + /// Define a callback to be called once user completes the flow + Function(Credential? credentials) callback = (credentials) => print(credentials.toString()); + + /// Example parameters + var authServerUrl = "https://localhost"; + var clientId = "clientid"; + var clientSecret = "clientSecret"; + var scopes = ["openid","email","profile"]; + + var _issuer = await Issuer.discover(Uri.parse(authServerUrl)); + var _client = Client( + _issuer, + clientId, + clientSecret: clientSecret, + ); + var _flow = Flow.device( + _client, + scopes: scopes, + ); + + /// this will get the device code to show to user and will call the callback when the user completed the flow + var deviceCode = await _flow.getDeviceCode((credentials) => callback(credentials)); +``` ## Command line tool diff --git a/lib/src/model/metadata.dart b/lib/src/model/metadata.dart index f9aba41..e918d70 100644 --- a/lib/src/model/metadata.dart +++ b/lib/src/model/metadata.dart @@ -14,6 +14,10 @@ class OpenIdProviderMetadata extends JsonObject { /// URL of the OP's UserInfo Endpoint. Uri? get userinfoEndpoint => getTyped('userinfo_endpoint'); + /// URL of the OP's Device Authorization Endpoint + Uri? get deviceAuthorizationEndpoint => + getTyped('device_authorization_endpoint'); + /// URL of the OP's JSON Web Key Set document. /// /// This contains the signing key(s) the RP uses to validate signatures from the OP. diff --git a/lib/src/openid.dart b/lib/src/openid.dart index b37221d..8fee8d8 100644 --- a/lib/src/openid.dart +++ b/lib/src/openid.dart @@ -178,6 +178,27 @@ class Client { null); } +class DeviceCode { + final String deviceCode; + final int expiredIn; + final String userCode; + final String verificationUri; + final String verificationUriComplete; + + DeviceCode(this.deviceCode, this.expiredIn, this.userCode, + this.verificationUri, this.verificationUriComplete); + + factory DeviceCode.fromJson(Map json) { + return DeviceCode( + json['device_code'] as String, + json['expires_in'] as int, + json['user_code'] as String, + json['verification_uri'] as String, + json['verification_uri_complete'] as String, + ); + } +} + class Credential { TokenResponse _token; final Client client; @@ -358,6 +379,14 @@ extension _IssuerX on Issuer { } return endpoint; } + + Uri get deviceAuthorizationEndpoint { + var endpoint = metadata.deviceAuthorizationEndpoint; + if (endpoint == null) { + throw OpenIdException.missingDeviceAuthorizationEndpoint(); + } + return endpoint; + } } enum FlowType { @@ -366,6 +395,7 @@ enum FlowType { proofKeyForCodeExchange, jwtBearer, password, + device, } class Flow { @@ -409,6 +439,15 @@ class Flow { }; } + Flow.device(Client client, + {List scopes = const ['openid', 'profile', 'email']}) + : this._( + FlowType.device, + '', + client, + scopes: scopes, + ); + /// Creates a new [Flow] for the password flow. /// /// This flow can be used for active authentication by highly-trusted @@ -569,18 +608,83 @@ class Flow { if (type != FlowType.password) { throw UnsupportedError('Flow is not password'); } - var json = await http.post(client.issuer.tokenEndpoint, - body: { - 'grant_type': 'password', - 'username': username, - 'password': password, - 'scope': scopes.join(' '), - 'client_id': client.clientId, - }, - client: client.httpClient); + var json = await http.post( + client.issuer.tokenEndpoint, + body: { + 'grant_type': 'password', + 'username': username, + 'password': password, + 'scope': scopes.join(' '), + 'client_id': client.clientId, + }, + client: client.httpClient, + ); return Credential._(client, TokenResponse.fromJson(json), null); } + Future getDeviceCode( + Function(Credential? credentials) callback) async { + if (type != FlowType.device) { + throw UnsupportedError('Flow is not password'); + } + var json = await http.post( + client.issuer.deviceAuthorizationEndpoint, + body: { + 'scope': scopes.join(' '), + 'client_id': client.clientId, + 'client_secret': client.clientSecret, + }, + client: client.httpClient, + ); + var deviceCode = DeviceCode.fromJson(json); + + _fetchDeviceToken(deviceCode, callback); + + return deviceCode; + } + + Future _fetchDeviceToken( + DeviceCode deviceCode, + Function(Credential? credentials) callback, + ) async { + if (deviceCode.expiredIn > 0) { + try { + var json = (await http.post( + client.issuer.tokenEndpoint, + body: { + 'grant_type': 'urn:ietf:params:oauth:grant-type:device_code', + 'device_code': deviceCode.deviceCode, + 'client_id': client.clientId, + 'client_secret': client.clientSecret, + }, + client: client.httpClient, + )) as Map; + + callback(Credential._(client, TokenResponse.fromJson(json), null)); + } on OpenIdException catch (e) { + if (e.code != null && e.code == 'authorization_pending') { + var delay = 5; + Future.delayed( + Duration(seconds: delay), + () => _fetchDeviceToken( + DeviceCode( + deviceCode.deviceCode, + deviceCode.expiredIn - delay, + deviceCode.userCode, + deviceCode.verificationUri, + deviceCode.verificationUriComplete, + ), + callback, + )); + } else { + callback(null); + } + } + } else { + callback(null); + } + } + Future callback(Map response) async { if (response['state'] != state) { throw ArgumentError('State does not match'); @@ -677,6 +781,18 @@ class OpenIdException implements Exception { : this._('missing_token_endpoint', 'The issuer metadata does not contain a token endpoint.'); + /// Thrown when trying to get a token, but the token endpoint is missing from + /// the issuer metadata + const OpenIdException.missingDeviceAuthorizationEndpoint() + : this._('missing_device_authorization_endpoint', + 'The issuer metadata does not contain a device authorization endpoint.'); + + /// Thrown when trying to get a token, but the token endpoint is missing from + /// the issuer metadata + const OpenIdException.missingDeviceAuthorizationEndpoint() + : this._('missing_device_authorization_endpoint', + 'The issuer metadata does not contain a device authorization endpoint.'); + const OpenIdException._(this.code, this.message) : uri = null; OpenIdException(this.code, String? message, [this.uri])