From 3003965f56cc46fdc17f496e8bd3dc502124e38b Mon Sep 17 00:00:00 2001 From: IamPekka058 <59747867+IamPekka058@users.noreply.github.com> Date: Wed, 16 Apr 2025 17:17:35 +0200 Subject: [PATCH 1/3] :sparkles: Add Device Flow helper for OAuth2 authentication --- lib/src/common/util/device_flow.dart | 120 +++++++++++++++++++++++++++ 1 file changed, 120 insertions(+) create mode 100644 lib/src/common/util/device_flow.dart diff --git a/lib/src/common/util/device_flow.dart b/lib/src/common/util/device_flow.dart new file mode 100644 index 00000000..288184a4 --- /dev/null +++ b/lib/src/common/util/device_flow.dart @@ -0,0 +1,120 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:github_flutter/src/common.dart'; +import 'package:http/http.dart' as http; + +/// Device Flow Helper +/// +/// **Example**: +/// +/// +class DeviceFlow { + /// OAuth2 Client ID + final String clientId; + + /// Requested Scopes + final List scopes; + + /// Grant type + final String? grantType; + + /// State + final String? state; + + /// Device flow Base URL + final String baseUrl; + + Map _response = {}; + + GitHub? github; + + DeviceFlow( + this.clientId, { + this.scopes = const [], + this.state, + this.github, + this.baseUrl = 'https://github.com', + this.grantType = "urn:ietf:params:oauth:grant-type:device_code", + }); + + Future fetchUserCode() async { + final headers = { + 'Accept': 'application/json', + 'content-type': 'application/json', + }; + + final body = GitHubJson.encode({ + 'client_id': clientId, + 'scope': scopes.join(','), + }); + + final response = await (github == null ? http.Client() : github!.client) + .post( + Uri.parse("$baseUrl/login/device/code"), + body: body, + headers: headers, + ); + + final json = jsonDecode(response.body) as Map; + + _response = json; + + if (json['error'] != null) { + throw Exception(json['error']); + } + return json['user_code']; + } + + /// Generates an Authorization URL + /// + /// This should be displayed to the user. + String createAuthorizeUrl() { + return '${_response['verification_uri']}?user_code=${_response['user_code']}'; + } + + /// Exchange `device code` after user verified for token + Future exchange() { + if (!_response.containsKey("deviceCode")) { + throw Error(); + } + + final headers = { + 'Accept': 'application/json', + 'content-type': 'application/json', + }; + + final body = GitHubJson.encode({ + 'client_id': clientId, + 'device_code': _response['deviceCode'], + 'grant_type': grantType, + }); + + return (github == null ? http.Client() : github!.client) + .post( + Uri.parse('$baseUrl/login/oauth/access_token'), + body: body, + headers: headers, + ) + .then((response) { + final json = jsonDecode(response.body) as Map; + if (json['error'] != null) { + throw Exception(json['error']); + } + return ExchangeResponse( + json['access_token'], + json['token_type'], + (json['scope'] as String).split(','), + ); + }); + } +} + +/// Represents a response for exchanging a code for a token. +class ExchangeResponse { + final String? token; + final List scopes; + final String? tokenType; + + ExchangeResponse(this.token, this.tokenType, this.scopes); +} From 51c8b420bba0c57af917b5746e4db4d87ce8c8da Mon Sep 17 00:00:00 2001 From: IamPekka058 <59747867+IamPekka058@users.noreply.github.com> Date: Wed, 16 Apr 2025 18:46:04 +0200 Subject: [PATCH 2/3] :recycle: Refactor DeviceFlow class and improve error handling --- lib/src/common/util/device_flow.dart | 35 ++++++++++++++-------------- 1 file changed, 18 insertions(+), 17 deletions(-) diff --git a/lib/src/common/util/device_flow.dart b/lib/src/common/util/device_flow.dart index 288184a4..334c1399 100644 --- a/lib/src/common/util/device_flow.dart +++ b/lib/src/common/util/device_flow.dart @@ -10,7 +10,7 @@ import 'package:http/http.dart' as http; /// /// class DeviceFlow { - /// OAuth2 Client ID + /// Client ID final String clientId; /// Requested Scopes @@ -19,21 +19,14 @@ class DeviceFlow { /// Grant type final String? grantType; - /// State - final String? state; - /// Device flow Base URL final String baseUrl; Map _response = {}; - GitHub? github; - DeviceFlow( this.clientId, { this.scopes = const [], - this.state, - this.github, this.baseUrl = 'https://github.com', this.grantType = "urn:ietf:params:oauth:grant-type:device_code", }); @@ -70,13 +63,14 @@ class DeviceFlow { /// /// This should be displayed to the user. String createAuthorizeUrl() { + if (_response['verification_uri'] == null) throw Error(); return '${_response['verification_uri']}?user_code=${_response['user_code']}'; } /// Exchange `device code` after user verified for token - Future exchange() { - if (!_response.containsKey("deviceCode")) { - throw Error(); + Future exchange() { + if (!_response.containsKey("device_code")) { + throw Exception("Device code not found"); } final headers = { @@ -86,7 +80,7 @@ class DeviceFlow { final body = GitHubJson.encode({ 'client_id': clientId, - 'device_code': _response['deviceCode'], + 'device_code': _response['device_code'], 'grant_type': grantType, }); @@ -99,22 +93,29 @@ class DeviceFlow { .then((response) { final json = jsonDecode(response.body) as Map; if (json['error'] != null) { - throw Exception(json['error']); + throw Exception(json['error'] ?? "Unknown error"); } - return ExchangeResponse( + return DeviceFlowExchangeResponse( json['access_token'], json['token_type'], (json['scope'] as String).split(','), + json['interval'] ?? 0, ); }); } } /// Represents a response for exchanging a code for a token. -class ExchangeResponse { +class DeviceFlowExchangeResponse { final String? token; final List scopes; final String? tokenType; - - ExchangeResponse(this.token, this.tokenType, this.scopes); + final int interval; + + DeviceFlowExchangeResponse( + this.token, + this.tokenType, + this.scopes, + this.interval, + ); } From 5bfd429aec5e0ff75bff4aa9e77ba169a76808a6 Mon Sep 17 00:00:00 2001 From: IamPekka058 <59747867+IamPekka058@users.noreply.github.com> Date: Wed, 16 Apr 2025 18:47:30 +0200 Subject: [PATCH 3/3] :sparkles: Add GitHub instance to DeviceFlow and update authorization URL error handling --- lib/src/common.dart | 1 + lib/src/common/util/device_flow.dart | 7 ++++++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/lib/src/common.dart b/lib/src/common.dart index a9c25c65..a0726858 100644 --- a/lib/src/common.dart +++ b/lib/src/common.dart @@ -5,6 +5,7 @@ library; export 'package:github_flutter/src/common/github.dart'; export 'package:github_flutter/src/common/util/auth.dart'; export 'package:github_flutter/src/common/util/crawler.dart'; +export 'package:github_flutter/src/common/util/device_flow.dart'; export 'package:github_flutter/src/common/util/errors.dart'; export 'package:github_flutter/src/common/util/json.dart'; export 'package:github_flutter/src/common/util/oauth2.dart'; diff --git a/lib/src/common/util/device_flow.dart b/lib/src/common/util/device_flow.dart index 334c1399..2230bfb0 100644 --- a/lib/src/common/util/device_flow.dart +++ b/lib/src/common/util/device_flow.dart @@ -22,6 +22,9 @@ class DeviceFlow { /// Device flow Base URL final String baseUrl; + /// GitHub instance + GitHub? github; + Map _response = {}; DeviceFlow( @@ -63,7 +66,9 @@ class DeviceFlow { /// /// This should be displayed to the user. String createAuthorizeUrl() { - if (_response['verification_uri'] == null) throw Error(); + if (_response['verification_uri'] == null) { + throw Error(); + } return '${_response['verification_uri']}?user_code=${_response['user_code']}'; }