From 2f513f31aaf8b0d83a29521204b785ff2d543ad4 Mon Sep 17 00:00:00 2001 From: lfabbri Date: Mon, 13 Feb 2023 20:36:16 +0100 Subject: [PATCH 1/6] wip --- lib/src/openid.dart | 108 +++++++++++++++++--------------------------- 1 file changed, 41 insertions(+), 67 deletions(-) diff --git a/lib/src/openid.dart b/lib/src/openid.dart index 69a7dd2..dae8943 100644 --- a/lib/src/openid.dart +++ b/lib/src/openid.dart @@ -42,14 +42,12 @@ class Issuer { static final Uri yahoo = Uri.parse('https://api.login.yahoo.com'); /// Url of the microsoft issuer. - static final Uri microsoft = - Uri.parse('https://login.microsoftonline.com/common'); + static final Uri microsoft = Uri.parse('https://login.microsoftonline.com/common'); /// Url of the salesforce issuer. static final Uri salesforce = Uri.parse('https://login.salesforce.com'); - static Uri firebase(String id) => - Uri.parse('https://securetoken.google.com/$id'); + static Uri firebase(String id) => Uri.parse('https://securetoken.google.com/$id'); static final Map _discoveries = { facebook: Issuer(OpenIdProviderMetadata.fromJson({ @@ -144,8 +142,7 @@ class Client { Client(this.issuer, this.clientId, {this.clientSecret, this.httpClient}); - static Future forIdToken(String idToken, - {http.Client? httpClient}) async { + static Future forIdToken(String idToken, {http.Client? httpClient}) async { var token = JsonWebToken.unverified(idToken); var claims = OpenIdClaims.fromJson(token.claims.toJson()); var issuer = await Issuer.discover(claims.issuer, httpClient: httpClient); @@ -172,8 +169,7 @@ class Client { 'refresh_token': refreshToken, 'id_token': idToken, if (expiresIn != null) 'expires_in': expiresIn.inSeconds, - if (expiresAt != null) - 'expires_at': expiresAt.millisecondsSinceEpoch ~/ 1000 + if (expiresAt != null) 'expires_at': expiresAt.millisecondsSinceEpoch ~/ 1000 }), null); } @@ -183,8 +179,7 @@ class Credential { final Client client; final String? nonce; - final StreamController _onTokenChanged = - StreamController.broadcast(); + final StreamController _onTokenChanged = StreamController.broadcast(); Credential._(this.client, this._token, this.nonce); @@ -229,15 +224,13 @@ class Credential { Uri? generateLogoutUrl({Uri? redirectUri, String? state}) { return client.issuer.metadata.endSessionEndpoint?.replace(queryParameters: { 'id_token_hint': _token.idToken.toCompactSerialization(), - if (redirectUri != null) - 'post_logout_redirect_uri': redirectUri.toString(), + if (redirectUri != null) 'post_logout_redirect_uri': redirectUri.toString(), if (state != null) 'state': state }); } http.Client createHttpClient([http.Client? baseClient]) => - http.AuthorizedClient( - baseClient ?? client.httpClient ?? http.Client(), this); + http.AuthorizedClient(baseClient ?? client.httpClient ?? http.Client(), this); Future _get(uri) async { return http.get(uri, client: createHttpClient()); @@ -249,16 +242,14 @@ class Credential { IdToken get idToken => _token.idToken; - Stream validateToken( - {bool validateClaims = true, bool validateExpiry = true}) async* { + Stream validateToken({bool validateClaims = true, bool validateExpiry = true}) async* { var keyStore = JsonWebKeyStore(); var jwksUri = client.issuer.metadata.jwksUri; if (jwksUri != null) { keyStore.addKeySetUrl(jwksUri); } if (!await idToken.verify(keyStore, - allowedArguments: - client.issuer.metadata.idTokenSigningAlgValuesSupported)) { + allowedArguments: client.issuer.metadata.idTokenSigningAlgValuesSupported)) { yield JoseException('Could not verify token signature'); } @@ -269,8 +260,7 @@ class Credential { clientId: client.clientId, nonce: nonce) .where((e) => - validateExpiry || - !(e is JoseException && e.message.startsWith('JWT expired.')))); + validateExpiry || !(e is JoseException && e.message.startsWith('JWT expired.')))); } String? get refreshToken => _token.refreshToken; @@ -278,8 +268,7 @@ class Credential { Future getTokenResponse([bool forceRefresh = false]) async { if (!forceRefresh && _token.accessToken != null && - (_token.expiresAt == null || - _token.expiresAt!.isAfter(DateTime.now()))) { + (_token.expiresAt == null || _token.expiresAt!.isAfter(DateTime.now()))) { return _token; } if (_token.accessToken == null && _token.refreshToken == null) { @@ -306,19 +295,15 @@ class Credential { /// used to update the token manually, e.g. when no refresh token is available /// and the token is updated by other means. void updateToken(Map json) { - _token = - TokenResponse.fromJson({'refresh_token': _token.refreshToken, ...json}); + _token = TokenResponse.fromJson({'refresh_token': _token.refreshToken, ...json}); _onTokenChanged.add(_token); } Credential.fromJson(Map json, {http.Client? httpClient}) : this._( - Client( - Issuer(OpenIdProviderMetadata.fromJson( - (json['issuer'] as Map).cast())), + Client(Issuer(OpenIdProviderMetadata.fromJson((json['issuer'] as Map).cast())), json['client_id'], - clientSecret: json['client_secret'], - httpClient: httpClient), + clientSecret: json['client_secret'], httpClient: httpClient), TokenResponse.fromJson((json['token'] as Map).cast()), json['nonce']); @@ -347,6 +332,7 @@ enum FlowType { proofKeyForCodeExchange, jwtBearer, password, + device, } class Flow { @@ -384,19 +370,23 @@ class Flow { var challenge = base64Url .encode(SHA256Digest().process(Uint8List.fromList(verifier.codeUnits))) .replaceAll('=', ''); - _proofKeyForCodeExchange = { - 'code_verifier': verifier, - 'code_challenge': challenge - }; + _proofKeyForCodeExchange = {'code_verifier': verifier, 'code_challenge': challenge}; } + 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 /// applications. Call [Flow.loginWithPassword] to authenticate a user with /// their username and password. - Flow.password(Client client, - {List scopes = const ['openid', 'profile', 'email']}) + Flow.password(Client client, {List scopes = const ['openid', 'profile', 'email']}) : this._( FlowType.password, '', @@ -445,8 +435,7 @@ class Flow { 'id_token token', 'id_token', 'token', - ].firstWhere((v) => - client.issuer.metadata.responseTypesSupported.contains(v)), + ].firstWhere((v) => client.issuer.metadata.responseTypesSupported.contains(v)), client, state: state, scopes: [ @@ -477,8 +466,7 @@ class Flow { 'client_id': client.clientId, 'redirect_uri': redirectUri.toString(), 'state': state - }..addAll( - responseType!.split(' ').contains('id_token') ? {'nonce': _nonce} : {}); + }..addAll(responseType!.split(' ').contains('id_token') ? {'nonce': _nonce} : {}); if (type == FlowType.proofKeyForCodeExchange) { v.addAll({ @@ -506,8 +494,7 @@ class Flow { 'code': code, 'redirect_uri': redirectUri.toString(), 'client_id': client.clientId, - if (client.clientSecret != null) - 'client_secret': client.clientSecret, + if (client.clientSecret != null) 'client_secret': client.clientSecret, 'code_verifier': _proofKeyForCodeExchange['code_verifier'] }, client: client.httpClient); @@ -522,8 +509,7 @@ class Flow { }, client: client.httpClient); } else if (methods.contains('client_secret_basic')) { - var h = - base64.encode('${client.clientId}:${client.clientSecret}'.codeUnits); + var h = base64.encode('${client.clientId}:${client.clientSecret}'.codeUnits); json = await http.post(client.issuer.tokenEndpoint, headers: {'authorization': 'Basic $h'}, body: { @@ -541,8 +527,7 @@ class Flow { /// Login with username and password /// /// Only allowed for [Flow.password] flows. - Future loginWithPassword( - {required String username, required String password}) async { + Future loginWithPassword({required String username, required String password}) async { if (type != FlowType.password) { throw UnsupportedError('Flow is not password'); } @@ -566,12 +551,10 @@ class Flow { var code = response['jwt']; return Credential._(client, await _getToken(code), null); } else if (response.containsKey('code') && - (type == FlowType.proofKeyForCodeExchange || - client.clientSecret != null)) { + (type == FlowType.proofKeyForCodeExchange || client.clientSecret != null)) { var code = response['code']; return Credential._(client, await _getToken(code), null); - } else if (response.containsKey('access_token') || - response.containsKey('id_token')) { + } else if (response.containsKey('access_token') || response.containsKey('id_token')) { return Credential._(client, TokenResponse.fromJson(response), _nonce); } else { return Credential._(client, TokenResponse.fromJson(response), _nonce); @@ -582,8 +565,7 @@ class Flow { String _randomString(int length) { var r = Random.secure(); var chars = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'; - return Iterable.generate(length, (_) => chars[r.nextInt(chars.length)]) - .join(); + return Iterable.generate(length, (_) => chars[r.nextInt(chars.length)]).join(); } /// An exception thrown when a response is received in the openid error format. @@ -614,16 +596,14 @@ class OpenIdException implements Exception { 'The User Questioning Request is not valid. The request is missing a required parameter, includes an unsupported parameter value (other than grant type), repeats a parameter, includes multiple credentials, utilizes more than one mechanism for authenticating the client, or is otherwise malformed.', 'no_suitable_method': 'There is no Questioning Method suitable with the User Questioning Request. The OP can use this error code when it does not implement mechanisms suitable for the wished AMR or ACR.', - 'timeout': - 'The Questioned User did not answer in the allowed period of time.', + 'timeout': 'The Questioned User did not answer in the allowed period of time.', 'unauthorized': 'The Client is not authorized to use the User Questioning API or did not send a valid Access Token.', 'unknown_user': 'The Questioned User mentioned in the user_id attribute of the User Questioning Request is unknown.', 'unreachable_user': 'The Questioned User mentioned in the User Questioning Request (either in the Access Token or in the user_id attribute) is unreachable. The OP can use this error when it does not have a reachability identifier (e.g. MSISDN) for the Question User or when the reachability identifier is not operational (e.g. unsubscribed MSISDN).', - 'user_refused_to_answer': - 'The Questioned User refused to make a statement to the question.', + 'user_refused_to_answer': 'The Questioned User refused to make a statement to the question.', 'interaction_required': 'The Authorization Server requires End-User interaction of some form to proceed. This error MAY be returned when the prompt parameter value in the Authentication Request is none, but the Authentication Request cannot be completed without displaying a user interface for End-User interaction.', 'login_required': @@ -634,16 +614,11 @@ class OpenIdException implements Exception { 'The Authorization Server requires End-User consent. This error MAY be returned when the prompt parameter value in the Authentication Request is none, but the Authentication Request cannot be completed without displaying a user interface for End-User consent.', 'invalid_request_uri': 'The request_uri in the Authorization Request returns an error or contains invalid data.', - 'invalid_request_object': - 'The request parameter contains an invalid Request Object.', - 'request_not_supported': - 'The OP does not support use of the request parameter', - 'request_uri_not_supported': - 'The OP does not support use of the request_uri parameter', - 'registration_not_supported': - 'The OP does not support use of the registration parameter', - 'invalid_redirect_uri': - 'The value of one or more redirect_uris is invalid.', + 'invalid_request_object': 'The request parameter contains an invalid Request Object.', + 'request_not_supported': 'The OP does not support use of the request parameter', + 'request_uri_not_supported': 'The OP does not support use of the request_uri parameter', + 'registration_not_supported': 'The OP does not support use of the registration parameter', + 'invalid_redirect_uri': 'The value of one or more redirect_uris is invalid.', 'invalid_client_metadata': 'The value of one of the Client Metadata fields is invalid and the server has rejected this request. Note that an Authorization Server MAY choose to substitute a valid value for any requested parameter of a Client\'s Metadata.', }; @@ -651,8 +626,7 @@ class OpenIdException implements Exception { /// Thrown when trying to get a token, but the token endpoint is missing from /// the issuer metadata const OpenIdException.missingTokenEndpoint() - : this._('missing_token_endpoint', - 'The issuer metadata does not contain a token endpoint.'); + : this._('missing_token_endpoint', 'The issuer metadata does not contain a token endpoint.'); const OpenIdException._(this.code, this.message) : uri = null; From 985b678afb9843d4568452cc4b7c5cb6d5f50f00 Mon Sep 17 00:00:00 2001 From: lfabbri Date: Mon, 13 Feb 2023 20:36:16 +0100 Subject: [PATCH 2/6] Device Authoriation Flow --- lib/src/model/metadata.dart | 36 +++--- lib/src/openid.dart | 217 ++++++++++++++++++++++++------------ 2 files changed, 157 insertions(+), 96 deletions(-) diff --git a/lib/src/model/metadata.dart b/lib/src/model/metadata.dart index f9aba41..bba7ea6 100644 --- a/lib/src/model/metadata.dart +++ b/lib/src/model/metadata.dart @@ -14,6 +14,9 @@ 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. @@ -26,16 +29,13 @@ class OpenIdProviderMetadata extends JsonObject { List? get scopesSupported => getTypedList('scopes_supported'); /// A list of the OAuth 2.0 `response_type` values that this OP supports. - List get responseTypesSupported => - getTypedList('response_types_supported')!; + List get responseTypesSupported => getTypedList('response_types_supported')!; /// A list of the OAuth 2.0 `response_mode` values that this OP supports. - List? get responseModesSupported => - getTypedList('response_modes_supported'); + List? get responseModesSupported => getTypedList('response_modes_supported'); /// A list of the OAuth 2.0 Grant Type values that this OP supports. - List? get grantTypesSupported => - getTypedList('grant_types_supported'); + List? get grantTypesSupported => getTypedList('grant_types_supported'); /// A list of the Authentication Context Class References that this OP supports. List? get acrValuesSupported => getTypedList('acr_values_supported'); @@ -43,8 +43,7 @@ class OpenIdProviderMetadata extends JsonObject { /// A list of the Subject Identifier types that this OP supports. /// /// Valid types include `pairwise` and `public`. - List get subjectTypesSupported => - getTypedList('subject_types_supported')!; + List get subjectTypesSupported => getTypedList('subject_types_supported')!; /// A list of the JWS signing algorithms (`alg` values) supported by the OP for /// the ID Token to encode the Claims in a JWT. @@ -122,15 +121,13 @@ class OpenIdProviderMetadata extends JsonObject { getTypedList('token_endpoint_auth_signing_alg_values_supported'); /// A list of the display parameter values that the OpenID Provider supports. - List? get displayValuesSupported => - getTypedList('display_values_supported'); + List? get displayValuesSupported => getTypedList('display_values_supported'); /// A list of the Claim Types that the OpenID Provider supports. /// /// Values defined by the specification are `normal`, `aggregated`, and /// `distributed`. If omitted, the implementation supports only `normal` Claims. - List? get claimTypesSupported => - getTypedList('claim_types_supported'); + List? get claimTypesSupported => getTypedList('claim_types_supported'); /// A list of the Claim Names of the Claims that the OpenID Provider MAY be /// able to supply values for. @@ -146,28 +143,23 @@ class OpenIdProviderMetadata extends JsonObject { /// Languages and scripts supported for values in Claims being returned. /// /// Not all languages and scripts are necessarily supported for all Claim values. - List? get claimsLocalesSupported => - getTypedList('claims_locales_supported'); + List? get claimsLocalesSupported => getTypedList('claims_locales_supported'); /// Languages and scripts supported for the user interface. List? get uiLocalesSupported => getTypedList('ui_locales_supported'); /// `true` when the OP supports use of the `claims` parameter. - bool get claimsParameterSupported => - this['claims_parameter_supported'] ?? false; + bool get claimsParameterSupported => this['claims_parameter_supported'] ?? false; /// `true` when the OP supports use of the `request` parameter. - bool get requestParameterSupported => - this['request_parameter_supported'] ?? false; + bool get requestParameterSupported => this['request_parameter_supported'] ?? false; /// `true` when the OP supports use of the `request_uri` parameter. - bool get requestUriParameterSupported => - this['request_uri_parameter_supported'] ?? true; + bool get requestUriParameterSupported => this['request_uri_parameter_supported'] ?? true; /// `true` when the OP requires any `request_uri` values used to be /// pre-registered using the request_uris registration parameter. - bool get requireRequestUriRegistration => - this['require_request_uri_registration'] ?? false; + bool get requireRequestUriRegistration => this['require_request_uri_registration'] ?? false; /// URL that the OpenID Provider provides to the person registering the Client /// to read about the OP's requirements on how the Relying Party can use the diff --git a/lib/src/openid.dart b/lib/src/openid.dart index 69a7dd2..ad5d371 100644 --- a/lib/src/openid.dart +++ b/lib/src/openid.dart @@ -42,14 +42,12 @@ class Issuer { static final Uri yahoo = Uri.parse('https://api.login.yahoo.com'); /// Url of the microsoft issuer. - static final Uri microsoft = - Uri.parse('https://login.microsoftonline.com/common'); + static final Uri microsoft = Uri.parse('https://login.microsoftonline.com/common'); /// Url of the salesforce issuer. static final Uri salesforce = Uri.parse('https://login.salesforce.com'); - static Uri firebase(String id) => - Uri.parse('https://securetoken.google.com/$id'); + static Uri firebase(String id) => Uri.parse('https://securetoken.google.com/$id'); static final Map _discoveries = { facebook: Issuer(OpenIdProviderMetadata.fromJson({ @@ -144,8 +142,7 @@ class Client { Client(this.issuer, this.clientId, {this.clientSecret, this.httpClient}); - static Future forIdToken(String idToken, - {http.Client? httpClient}) async { + static Future forIdToken(String idToken, {http.Client? httpClient}) async { var token = JsonWebToken.unverified(idToken); var claims = OpenIdClaims.fromJson(token.claims.toJson()); var issuer = await Issuer.discover(claims.issuer, httpClient: httpClient); @@ -172,19 +169,38 @@ class Client { 'refresh_token': refreshToken, 'id_token': idToken, if (expiresIn != null) 'expires_in': expiresIn.inSeconds, - if (expiresAt != null) - 'expires_at': expiresAt.millisecondsSinceEpoch ~/ 1000 + if (expiresAt != null) 'expires_at': expiresAt.millisecondsSinceEpoch ~/ 1000 }), 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; final String? nonce; - final StreamController _onTokenChanged = - StreamController.broadcast(); + final StreamController _onTokenChanged = StreamController.broadcast(); Credential._(this.client, this._token, this.nonce); @@ -229,15 +245,13 @@ class Credential { Uri? generateLogoutUrl({Uri? redirectUri, String? state}) { return client.issuer.metadata.endSessionEndpoint?.replace(queryParameters: { 'id_token_hint': _token.idToken.toCompactSerialization(), - if (redirectUri != null) - 'post_logout_redirect_uri': redirectUri.toString(), + if (redirectUri != null) 'post_logout_redirect_uri': redirectUri.toString(), if (state != null) 'state': state }); } http.Client createHttpClient([http.Client? baseClient]) => - http.AuthorizedClient( - baseClient ?? client.httpClient ?? http.Client(), this); + http.AuthorizedClient(baseClient ?? client.httpClient ?? http.Client(), this); Future _get(uri) async { return http.get(uri, client: createHttpClient()); @@ -249,16 +263,14 @@ class Credential { IdToken get idToken => _token.idToken; - Stream validateToken( - {bool validateClaims = true, bool validateExpiry = true}) async* { + Stream validateToken({bool validateClaims = true, bool validateExpiry = true}) async* { var keyStore = JsonWebKeyStore(); var jwksUri = client.issuer.metadata.jwksUri; if (jwksUri != null) { keyStore.addKeySetUrl(jwksUri); } if (!await idToken.verify(keyStore, - allowedArguments: - client.issuer.metadata.idTokenSigningAlgValuesSupported)) { + allowedArguments: client.issuer.metadata.idTokenSigningAlgValuesSupported)) { yield JoseException('Could not verify token signature'); } @@ -269,8 +281,7 @@ class Credential { clientId: client.clientId, nonce: nonce) .where((e) => - validateExpiry || - !(e is JoseException && e.message.startsWith('JWT expired.')))); + validateExpiry || !(e is JoseException && e.message.startsWith('JWT expired.')))); } String? get refreshToken => _token.refreshToken; @@ -278,8 +289,7 @@ class Credential { Future getTokenResponse([bool forceRefresh = false]) async { if (!forceRefresh && _token.accessToken != null && - (_token.expiresAt == null || - _token.expiresAt!.isAfter(DateTime.now()))) { + (_token.expiresAt == null || _token.expiresAt!.isAfter(DateTime.now()))) { return _token; } if (_token.accessToken == null && _token.refreshToken == null) { @@ -306,19 +316,15 @@ class Credential { /// used to update the token manually, e.g. when no refresh token is available /// and the token is updated by other means. void updateToken(Map json) { - _token = - TokenResponse.fromJson({'refresh_token': _token.refreshToken, ...json}); + _token = TokenResponse.fromJson({'refresh_token': _token.refreshToken, ...json}); _onTokenChanged.add(_token); } Credential.fromJson(Map json, {http.Client? httpClient}) : this._( - Client( - Issuer(OpenIdProviderMetadata.fromJson( - (json['issuer'] as Map).cast())), + Client(Issuer(OpenIdProviderMetadata.fromJson((json['issuer'] as Map).cast())), json['client_id'], - clientSecret: json['client_secret'], - httpClient: httpClient), + clientSecret: json['client_secret'], httpClient: httpClient), TokenResponse.fromJson((json['token'] as Map).cast()), json['nonce']); @@ -339,6 +345,14 @@ extension _IssuerX on Issuer { } return endpoint; } + + Uri get deviceAuthorizationEndpoint { + var endpoint = metadata.deviceAuthorizationEndpoint; + if (endpoint == null) { + throw OpenIdException.missingDeviceAuthorizationEndpoint(); + } + return endpoint; + } } enum FlowType { @@ -347,6 +361,7 @@ enum FlowType { proofKeyForCodeExchange, jwtBearer, password, + device, } class Flow { @@ -384,19 +399,23 @@ class Flow { var challenge = base64Url .encode(SHA256Digest().process(Uint8List.fromList(verifier.codeUnits))) .replaceAll('=', ''); - _proofKeyForCodeExchange = { - 'code_verifier': verifier, - 'code_challenge': challenge - }; + _proofKeyForCodeExchange = {'code_verifier': verifier, 'code_challenge': challenge}; } + 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 /// applications. Call [Flow.loginWithPassword] to authenticate a user with /// their username and password. - Flow.password(Client client, - {List scopes = const ['openid', 'profile', 'email']}) + Flow.password(Client client, {List scopes = const ['openid', 'profile', 'email']}) : this._( FlowType.password, '', @@ -445,8 +464,7 @@ class Flow { 'id_token token', 'id_token', 'token', - ].firstWhere((v) => - client.issuer.metadata.responseTypesSupported.contains(v)), + ].firstWhere((v) => client.issuer.metadata.responseTypesSupported.contains(v)), client, state: state, scopes: [ @@ -477,8 +495,7 @@ class Flow { 'client_id': client.clientId, 'redirect_uri': redirectUri.toString(), 'state': state - }..addAll( - responseType!.split(' ').contains('id_token') ? {'nonce': _nonce} : {}); + }..addAll(responseType!.split(' ').contains('id_token') ? {'nonce': _nonce} : {}); if (type == FlowType.proofKeyForCodeExchange) { v.addAll({ @@ -506,8 +523,7 @@ class Flow { 'code': code, 'redirect_uri': redirectUri.toString(), 'client_id': client.clientId, - if (client.clientSecret != null) - 'client_secret': client.clientSecret, + if (client.clientSecret != null) 'client_secret': client.clientSecret, 'code_verifier': _proofKeyForCodeExchange['code_verifier'] }, client: client.httpClient); @@ -522,8 +538,7 @@ class Flow { }, client: client.httpClient); } else if (methods.contains('client_secret_basic')) { - var h = - base64.encode('${client.clientId}:${client.clientSecret}'.codeUnits); + var h = base64.encode('${client.clientId}:${client.clientSecret}'.codeUnits); json = await http.post(client.issuer.tokenEndpoint, headers: {'authorization': 'Basic $h'}, body: { @@ -541,21 +556,80 @@ class Flow { /// Login with username and password /// /// Only allowed for [Flow.password] flows. - Future loginWithPassword( - {required String username, required String password}) async { + Future loginWithPassword({required String username, required String password}) async { if (type != FlowType.password) { throw UnsupportedError('Flow is not password'); } - var json = await http.post(client.issuer.tokenEndpoint, + 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: 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) { + var json = (await http.post( + client.issuer.tokenEndpoint, body: { - 'grant_type': 'password', - 'username': username, - 'password': password, - 'scope': scopes.join(' '), + 'grant_type': 'urn:ietf:params:oauth:grant-type:device_code', + 'device_code': deviceCode.deviceCode, 'client_id': client.clientId, }, - client: client.httpClient); - return Credential._(client, TokenResponse.fromJson(json), null); + client: client.httpClient, + )) as Map; + + if (json['error'] != null && json['error'] == 'authorization_pending') { + var delay = 5; + await Future.delayed( + Duration(seconds: delay), + () => _fetchDeviceToken( + DeviceCode( + deviceCode.deviceCode, + deviceCode.expiredIn - delay, + deviceCode.userCode, + deviceCode.verificationUri, + deviceCode.verificationUriComplete, + ), + callback, + )); + } else if (json['access_token'] != null) { + callback(Credential._(client, TokenResponse.fromJson(json), null)); + } else { + callback(null); + } + } else { + callback(null); + } } Future callback(Map response) async { @@ -566,12 +640,10 @@ class Flow { var code = response['jwt']; return Credential._(client, await _getToken(code), null); } else if (response.containsKey('code') && - (type == FlowType.proofKeyForCodeExchange || - client.clientSecret != null)) { + (type == FlowType.proofKeyForCodeExchange || client.clientSecret != null)) { var code = response['code']; return Credential._(client, await _getToken(code), null); - } else if (response.containsKey('access_token') || - response.containsKey('id_token')) { + } else if (response.containsKey('access_token') || response.containsKey('id_token')) { return Credential._(client, TokenResponse.fromJson(response), _nonce); } else { return Credential._(client, TokenResponse.fromJson(response), _nonce); @@ -582,8 +654,7 @@ class Flow { String _randomString(int length) { var r = Random.secure(); var chars = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'; - return Iterable.generate(length, (_) => chars[r.nextInt(chars.length)]) - .join(); + return Iterable.generate(length, (_) => chars[r.nextInt(chars.length)]).join(); } /// An exception thrown when a response is received in the openid error format. @@ -614,16 +685,14 @@ class OpenIdException implements Exception { 'The User Questioning Request is not valid. The request is missing a required parameter, includes an unsupported parameter value (other than grant type), repeats a parameter, includes multiple credentials, utilizes more than one mechanism for authenticating the client, or is otherwise malformed.', 'no_suitable_method': 'There is no Questioning Method suitable with the User Questioning Request. The OP can use this error code when it does not implement mechanisms suitable for the wished AMR or ACR.', - 'timeout': - 'The Questioned User did not answer in the allowed period of time.', + 'timeout': 'The Questioned User did not answer in the allowed period of time.', 'unauthorized': 'The Client is not authorized to use the User Questioning API or did not send a valid Access Token.', 'unknown_user': 'The Questioned User mentioned in the user_id attribute of the User Questioning Request is unknown.', 'unreachable_user': 'The Questioned User mentioned in the User Questioning Request (either in the Access Token or in the user_id attribute) is unreachable. The OP can use this error when it does not have a reachability identifier (e.g. MSISDN) for the Question User or when the reachability identifier is not operational (e.g. unsubscribed MSISDN).', - 'user_refused_to_answer': - 'The Questioned User refused to make a statement to the question.', + 'user_refused_to_answer': 'The Questioned User refused to make a statement to the question.', 'interaction_required': 'The Authorization Server requires End-User interaction of some form to proceed. This error MAY be returned when the prompt parameter value in the Authentication Request is none, but the Authentication Request cannot be completed without displaying a user interface for End-User interaction.', 'login_required': @@ -634,16 +703,11 @@ class OpenIdException implements Exception { 'The Authorization Server requires End-User consent. This error MAY be returned when the prompt parameter value in the Authentication Request is none, but the Authentication Request cannot be completed without displaying a user interface for End-User consent.', 'invalid_request_uri': 'The request_uri in the Authorization Request returns an error or contains invalid data.', - 'invalid_request_object': - 'The request parameter contains an invalid Request Object.', - 'request_not_supported': - 'The OP does not support use of the request parameter', - 'request_uri_not_supported': - 'The OP does not support use of the request_uri parameter', - 'registration_not_supported': - 'The OP does not support use of the registration parameter', - 'invalid_redirect_uri': - 'The value of one or more redirect_uris is invalid.', + 'invalid_request_object': 'The request parameter contains an invalid Request Object.', + 'request_not_supported': 'The OP does not support use of the request parameter', + 'request_uri_not_supported': 'The OP does not support use of the request_uri parameter', + 'registration_not_supported': 'The OP does not support use of the registration parameter', + 'invalid_redirect_uri': 'The value of one or more redirect_uris is invalid.', 'invalid_client_metadata': 'The value of one of the Client Metadata fields is invalid and the server has rejected this request. Note that an Authorization Server MAY choose to substitute a valid value for any requested parameter of a Client\'s Metadata.', }; @@ -651,8 +715,13 @@ class OpenIdException implements Exception { /// Thrown when trying to get a token, but the token endpoint is missing from /// the issuer metadata const OpenIdException.missingTokenEndpoint() - : this._('missing_token_endpoint', - 'The issuer metadata does not contain a token endpoint.'); + : 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.'); const OpenIdException._(this.code, this.message) : uri = null; From fdfca43ee807c97646d4591c21515fabbdd77476 Mon Sep 17 00:00:00 2001 From: Luca Fabbri Date: Tue, 14 Feb 2023 19:04:07 +0100 Subject: [PATCH 3/6] Update openid.dart --- lib/src/openid.dart | 100 +++++++++++++++++++------------------------- 1 file changed, 44 insertions(+), 56 deletions(-) diff --git a/lib/src/openid.dart b/lib/src/openid.dart index ad5d371..9a45d39 100644 --- a/lib/src/openid.dart +++ b/lib/src/openid.dart @@ -25,9 +25,7 @@ class Issuer { /// Creates an issuer from its metadata. Issuer(this.metadata, {this.claimsMap = const {}}) - : _keyStore = metadata.jwksUri == null - ? JsonWebKeyStore() - : (JsonWebKeyStore()..addKeySetUrl(metadata.jwksUri!)); + : _keyStore = metadata.jwksUri == null ? JsonWebKeyStore() : (JsonWebKeyStore()..addKeySetUrl(metadata.jwksUri!)); /// Url of the facebook issuer. /// @@ -181,8 +179,7 @@ class DeviceCode { final String verificationUri; final String verificationUriComplete; - DeviceCode(this.deviceCode, this.expiredIn, this.userCode, this.verificationUri, - this.verificationUriComplete); + DeviceCode(this.deviceCode, this.expiredIn, this.userCode, this.verificationUri, this.verificationUriComplete); factory DeviceCode.fromJson(Map json) { return DeviceCode( @@ -269,8 +266,7 @@ class Credential { if (jwksUri != null) { keyStore.addKeySetUrl(jwksUri); } - if (!await idToken.verify(keyStore, - allowedArguments: client.issuer.metadata.idTokenSigningAlgValuesSupported)) { + if (!await idToken.verify(keyStore, allowedArguments: client.issuer.metadata.idTokenSigningAlgValuesSupported)) { yield JoseException('Could not verify token signature'); } @@ -280,8 +276,7 @@ class Credential { issuer: client.issuer.metadata.issuer, clientId: client.clientId, nonce: nonce) - .where((e) => - validateExpiry || !(e is JoseException && e.message.startsWith('JWT expired.')))); + .where((e) => validateExpiry || !(e is JoseException && e.message.startsWith('JWT expired.')))); } String? get refreshToken => _token.refreshToken; @@ -322,8 +317,7 @@ class Credential { Credential.fromJson(Map json, {http.Client? httpClient}) : this._( - Client(Issuer(OpenIdProviderMetadata.fromJson((json['issuer'] as Map).cast())), - json['client_id'], + Client(Issuer(OpenIdProviderMetadata.fromJson((json['issuer'] as Map).cast())), json['client_id'], clientSecret: json['client_secret'], httpClient: httpClient), TokenResponse.fromJson((json['token'] as Map).cast()), json['nonce']); @@ -396,9 +390,8 @@ class Flow { } var verifier = codeVerifier ?? _randomString(50); - var challenge = base64Url - .encode(SHA256Digest().process(Uint8List.fromList(verifier.codeUnits))) - .replaceAll('=', ''); + var challenge = + base64Url.encode(SHA256Digest().process(Uint8List.fromList(verifier.codeUnits))).replaceAll('=', ''); _proofKeyForCodeExchange = {'code_verifier': verifier, 'code_challenge': challenge}; } @@ -480,8 +473,8 @@ class Flow { Flow.jwtBearer(Client client) : this._(FlowType.jwtBearer, null, client); - Uri get authenticationUri => client.issuer.metadata.authorizationEndpoint - .replace(queryParameters: _authenticationUriParameters); + Uri get authenticationUri => + client.issuer.metadata.authorizationEndpoint.replace(queryParameters: _authenticationUriParameters); late Map _proofKeyForCodeExchange; @@ -498,10 +491,7 @@ class Flow { }..addAll(responseType!.split(' ').contains('id_token') ? {'nonce': _nonce} : {}); if (type == FlowType.proofKeyForCodeExchange) { - v.addAll({ - 'code_challenge_method': 'S256', - 'code_challenge': _proofKeyForCodeExchange['code_challenge'] - }); + v.addAll({'code_challenge_method': 'S256', 'code_challenge': _proofKeyForCodeExchange['code_challenge']}); } return v; } @@ -541,11 +531,7 @@ class Flow { var h = base64.encode('${client.clientId}:${client.clientSecret}'.codeUnits); json = await http.post(client.issuer.tokenEndpoint, headers: {'authorization': 'Basic $h'}, - body: { - 'grant_type': 'authorization_code', - 'code': code, - 'redirect_uri': redirectUri.toString() - }, + body: {'grant_type': 'authorization_code', 'code': code, 'redirect_uri': redirectUri.toString()}, client: client.httpClient); } else { throw UnsupportedError('Unknown auth methods: $methods'); @@ -583,6 +569,7 @@ class Flow { body: { 'scope': scopes.join(' '), 'client_id': client.clientId, + 'client_secret': client.clientSecret, }, client: client.httpClient, ); @@ -598,34 +585,37 @@ class Flow { Function(Credential? credentials) callback, ) async { if (deviceCode.expiredIn > 0) { - 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: client.httpClient, - )) as Map; - - if (json['error'] != null && json['error'] == 'authorization_pending') { - var delay = 5; - await Future.delayed( - Duration(seconds: delay), - () => _fetchDeviceToken( - DeviceCode( - deviceCode.deviceCode, - deviceCode.expiredIn - delay, - deviceCode.userCode, - deviceCode.verificationUri, - deviceCode.verificationUriComplete, - ), - callback, - )); - } else if (json['access_token'] != null) { + 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)); - } else { - callback(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); @@ -701,8 +691,7 @@ class OpenIdException implements Exception { 'The End-User is REQUIRED to select a session at the Authorization Server. The End-User MAY be authenticated at the Authorization Server with different associated accounts, but the End-User did not select a session. This error MAY be returned when the prompt parameter value in the Authentication Request is none, but the Authentication Request cannot be completed without displaying a user interface to prompt for a session to use.', 'consent_required': 'The Authorization Server requires End-User consent. This error MAY be returned when the prompt parameter value in the Authentication Request is none, but the Authentication Request cannot be completed without displaying a user interface for End-User consent.', - 'invalid_request_uri': - 'The request_uri in the Authorization Request returns an error or contains invalid data.', + 'invalid_request_uri': 'The request_uri in the Authorization Request returns an error or contains invalid data.', 'invalid_request_object': 'The request parameter contains an invalid Request Object.', 'request_not_supported': 'The OP does not support use of the request parameter', 'request_uri_not_supported': 'The OP does not support use of the request_uri parameter', @@ -725,8 +714,7 @@ class OpenIdException implements Exception { const OpenIdException._(this.code, this.message) : uri = null; - OpenIdException(this.code, String? message, [this.uri]) - : message = message ?? _defaultMessages[code!]; + OpenIdException(this.code, String? message, [this.uri]) : message = message ?? _defaultMessages[code!]; @override String toString() => 'OpenIdException($code): $message'; From 2fc190c7378466fcde19aeeb5bb8fadd08cdeb45 Mon Sep 17 00:00:00 2001 From: Luca Fabbri Date: Tue, 14 Feb 2023 19:11:44 +0100 Subject: [PATCH 4/6] Update README.md --- README.md | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/README.md b/README.md index 5008d0c..a59bf9a 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 From 2058f6e67880e364cad2dbd4c281691046e2a179 Mon Sep 17 00:00:00 2001 From: Luca Fabbri Date: Tue, 14 Feb 2023 19:15:40 +0100 Subject: [PATCH 5/6] Squashed commit of the following: commit 2fc190c7378466fcde19aeeb5bb8fadd08cdeb45 Author: Luca Fabbri Date: Tue Feb 14 19:11:44 2023 +0100 Update README.md commit fdfca43ee807c97646d4591c21515fabbdd77476 Author: Luca Fabbri Date: Tue Feb 14 19:04:07 2023 +0100 Update openid.dart commit 1aacec3b4d64b95ea79eb02a7d214615a7ded2da Merge: 2f513f3 985b678 Author: Luca Fabbri Date: Tue Feb 14 10:10:41 2023 +0100 Merge branch 'feature/device-code-flow' of https://github.com/lucafabbri/openid_client into feature/device-code-flow commit 985b678afb9843d4568452cc4b7c5cb6d5f50f00 Author: lfabbri Date: Mon Feb 13 20:36:16 2023 +0100 Device Authoriation Flow commit 2f513f31aaf8b0d83a29521204b785ff2d543ad4 Author: lfabbri Date: Mon Feb 13 20:36:16 2023 +0100 wip --- README.md | 26 ++++ lib/src/model/metadata.dart | 36 ++--- lib/src/openid.dart | 257 ++++++++++++++++++++++-------------- 3 files changed, 197 insertions(+), 122 deletions(-) diff --git a/README.md b/README.md index 5008d0c..a59bf9a 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..bba7ea6 100644 --- a/lib/src/model/metadata.dart +++ b/lib/src/model/metadata.dart @@ -14,6 +14,9 @@ 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. @@ -26,16 +29,13 @@ class OpenIdProviderMetadata extends JsonObject { List? get scopesSupported => getTypedList('scopes_supported'); /// A list of the OAuth 2.0 `response_type` values that this OP supports. - List get responseTypesSupported => - getTypedList('response_types_supported')!; + List get responseTypesSupported => getTypedList('response_types_supported')!; /// A list of the OAuth 2.0 `response_mode` values that this OP supports. - List? get responseModesSupported => - getTypedList('response_modes_supported'); + List? get responseModesSupported => getTypedList('response_modes_supported'); /// A list of the OAuth 2.0 Grant Type values that this OP supports. - List? get grantTypesSupported => - getTypedList('grant_types_supported'); + List? get grantTypesSupported => getTypedList('grant_types_supported'); /// A list of the Authentication Context Class References that this OP supports. List? get acrValuesSupported => getTypedList('acr_values_supported'); @@ -43,8 +43,7 @@ class OpenIdProviderMetadata extends JsonObject { /// A list of the Subject Identifier types that this OP supports. /// /// Valid types include `pairwise` and `public`. - List get subjectTypesSupported => - getTypedList('subject_types_supported')!; + List get subjectTypesSupported => getTypedList('subject_types_supported')!; /// A list of the JWS signing algorithms (`alg` values) supported by the OP for /// the ID Token to encode the Claims in a JWT. @@ -122,15 +121,13 @@ class OpenIdProviderMetadata extends JsonObject { getTypedList('token_endpoint_auth_signing_alg_values_supported'); /// A list of the display parameter values that the OpenID Provider supports. - List? get displayValuesSupported => - getTypedList('display_values_supported'); + List? get displayValuesSupported => getTypedList('display_values_supported'); /// A list of the Claim Types that the OpenID Provider supports. /// /// Values defined by the specification are `normal`, `aggregated`, and /// `distributed`. If omitted, the implementation supports only `normal` Claims. - List? get claimTypesSupported => - getTypedList('claim_types_supported'); + List? get claimTypesSupported => getTypedList('claim_types_supported'); /// A list of the Claim Names of the Claims that the OpenID Provider MAY be /// able to supply values for. @@ -146,28 +143,23 @@ class OpenIdProviderMetadata extends JsonObject { /// Languages and scripts supported for values in Claims being returned. /// /// Not all languages and scripts are necessarily supported for all Claim values. - List? get claimsLocalesSupported => - getTypedList('claims_locales_supported'); + List? get claimsLocalesSupported => getTypedList('claims_locales_supported'); /// Languages and scripts supported for the user interface. List? get uiLocalesSupported => getTypedList('ui_locales_supported'); /// `true` when the OP supports use of the `claims` parameter. - bool get claimsParameterSupported => - this['claims_parameter_supported'] ?? false; + bool get claimsParameterSupported => this['claims_parameter_supported'] ?? false; /// `true` when the OP supports use of the `request` parameter. - bool get requestParameterSupported => - this['request_parameter_supported'] ?? false; + bool get requestParameterSupported => this['request_parameter_supported'] ?? false; /// `true` when the OP supports use of the `request_uri` parameter. - bool get requestUriParameterSupported => - this['request_uri_parameter_supported'] ?? true; + bool get requestUriParameterSupported => this['request_uri_parameter_supported'] ?? true; /// `true` when the OP requires any `request_uri` values used to be /// pre-registered using the request_uris registration parameter. - bool get requireRequestUriRegistration => - this['require_request_uri_registration'] ?? false; + bool get requireRequestUriRegistration => this['require_request_uri_registration'] ?? false; /// URL that the OpenID Provider provides to the person registering the Client /// to read about the OP's requirements on how the Relying Party can use the diff --git a/lib/src/openid.dart b/lib/src/openid.dart index 69a7dd2..9a45d39 100644 --- a/lib/src/openid.dart +++ b/lib/src/openid.dart @@ -25,9 +25,7 @@ class Issuer { /// Creates an issuer from its metadata. Issuer(this.metadata, {this.claimsMap = const {}}) - : _keyStore = metadata.jwksUri == null - ? JsonWebKeyStore() - : (JsonWebKeyStore()..addKeySetUrl(metadata.jwksUri!)); + : _keyStore = metadata.jwksUri == null ? JsonWebKeyStore() : (JsonWebKeyStore()..addKeySetUrl(metadata.jwksUri!)); /// Url of the facebook issuer. /// @@ -42,14 +40,12 @@ class Issuer { static final Uri yahoo = Uri.parse('https://api.login.yahoo.com'); /// Url of the microsoft issuer. - static final Uri microsoft = - Uri.parse('https://login.microsoftonline.com/common'); + static final Uri microsoft = Uri.parse('https://login.microsoftonline.com/common'); /// Url of the salesforce issuer. static final Uri salesforce = Uri.parse('https://login.salesforce.com'); - static Uri firebase(String id) => - Uri.parse('https://securetoken.google.com/$id'); + static Uri firebase(String id) => Uri.parse('https://securetoken.google.com/$id'); static final Map _discoveries = { facebook: Issuer(OpenIdProviderMetadata.fromJson({ @@ -144,8 +140,7 @@ class Client { Client(this.issuer, this.clientId, {this.clientSecret, this.httpClient}); - static Future forIdToken(String idToken, - {http.Client? httpClient}) async { + static Future forIdToken(String idToken, {http.Client? httpClient}) async { var token = JsonWebToken.unverified(idToken); var claims = OpenIdClaims.fromJson(token.claims.toJson()); var issuer = await Issuer.discover(claims.issuer, httpClient: httpClient); @@ -172,19 +167,37 @@ class Client { 'refresh_token': refreshToken, 'id_token': idToken, if (expiresIn != null) 'expires_in': expiresIn.inSeconds, - if (expiresAt != null) - 'expires_at': expiresAt.millisecondsSinceEpoch ~/ 1000 + if (expiresAt != null) 'expires_at': expiresAt.millisecondsSinceEpoch ~/ 1000 }), 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; final String? nonce; - final StreamController _onTokenChanged = - StreamController.broadcast(); + final StreamController _onTokenChanged = StreamController.broadcast(); Credential._(this.client, this._token, this.nonce); @@ -229,15 +242,13 @@ class Credential { Uri? generateLogoutUrl({Uri? redirectUri, String? state}) { return client.issuer.metadata.endSessionEndpoint?.replace(queryParameters: { 'id_token_hint': _token.idToken.toCompactSerialization(), - if (redirectUri != null) - 'post_logout_redirect_uri': redirectUri.toString(), + if (redirectUri != null) 'post_logout_redirect_uri': redirectUri.toString(), if (state != null) 'state': state }); } http.Client createHttpClient([http.Client? baseClient]) => - http.AuthorizedClient( - baseClient ?? client.httpClient ?? http.Client(), this); + http.AuthorizedClient(baseClient ?? client.httpClient ?? http.Client(), this); Future _get(uri) async { return http.get(uri, client: createHttpClient()); @@ -249,16 +260,13 @@ class Credential { IdToken get idToken => _token.idToken; - Stream validateToken( - {bool validateClaims = true, bool validateExpiry = true}) async* { + Stream validateToken({bool validateClaims = true, bool validateExpiry = true}) async* { var keyStore = JsonWebKeyStore(); var jwksUri = client.issuer.metadata.jwksUri; if (jwksUri != null) { keyStore.addKeySetUrl(jwksUri); } - if (!await idToken.verify(keyStore, - allowedArguments: - client.issuer.metadata.idTokenSigningAlgValuesSupported)) { + if (!await idToken.verify(keyStore, allowedArguments: client.issuer.metadata.idTokenSigningAlgValuesSupported)) { yield JoseException('Could not verify token signature'); } @@ -268,9 +276,7 @@ class Credential { issuer: client.issuer.metadata.issuer, clientId: client.clientId, nonce: nonce) - .where((e) => - validateExpiry || - !(e is JoseException && e.message.startsWith('JWT expired.')))); + .where((e) => validateExpiry || !(e is JoseException && e.message.startsWith('JWT expired.')))); } String? get refreshToken => _token.refreshToken; @@ -278,8 +284,7 @@ class Credential { Future getTokenResponse([bool forceRefresh = false]) async { if (!forceRefresh && _token.accessToken != null && - (_token.expiresAt == null || - _token.expiresAt!.isAfter(DateTime.now()))) { + (_token.expiresAt == null || _token.expiresAt!.isAfter(DateTime.now()))) { return _token; } if (_token.accessToken == null && _token.refreshToken == null) { @@ -306,19 +311,14 @@ class Credential { /// used to update the token manually, e.g. when no refresh token is available /// and the token is updated by other means. void updateToken(Map json) { - _token = - TokenResponse.fromJson({'refresh_token': _token.refreshToken, ...json}); + _token = TokenResponse.fromJson({'refresh_token': _token.refreshToken, ...json}); _onTokenChanged.add(_token); } Credential.fromJson(Map json, {http.Client? httpClient}) : this._( - Client( - Issuer(OpenIdProviderMetadata.fromJson( - (json['issuer'] as Map).cast())), - json['client_id'], - clientSecret: json['client_secret'], - httpClient: httpClient), + Client(Issuer(OpenIdProviderMetadata.fromJson((json['issuer'] as Map).cast())), json['client_id'], + clientSecret: json['client_secret'], httpClient: httpClient), TokenResponse.fromJson((json['token'] as Map).cast()), json['nonce']); @@ -339,6 +339,14 @@ extension _IssuerX on Issuer { } return endpoint; } + + Uri get deviceAuthorizationEndpoint { + var endpoint = metadata.deviceAuthorizationEndpoint; + if (endpoint == null) { + throw OpenIdException.missingDeviceAuthorizationEndpoint(); + } + return endpoint; + } } enum FlowType { @@ -347,6 +355,7 @@ enum FlowType { proofKeyForCodeExchange, jwtBearer, password, + device, } class Flow { @@ -381,22 +390,25 @@ class Flow { } var verifier = codeVerifier ?? _randomString(50); - var challenge = base64Url - .encode(SHA256Digest().process(Uint8List.fromList(verifier.codeUnits))) - .replaceAll('=', ''); - _proofKeyForCodeExchange = { - 'code_verifier': verifier, - 'code_challenge': challenge - }; + var challenge = + base64Url.encode(SHA256Digest().process(Uint8List.fromList(verifier.codeUnits))).replaceAll('=', ''); + _proofKeyForCodeExchange = {'code_verifier': verifier, 'code_challenge': challenge}; } + 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 /// applications. Call [Flow.loginWithPassword] to authenticate a user with /// their username and password. - Flow.password(Client client, - {List scopes = const ['openid', 'profile', 'email']}) + Flow.password(Client client, {List scopes = const ['openid', 'profile', 'email']}) : this._( FlowType.password, '', @@ -445,8 +457,7 @@ class Flow { 'id_token token', 'id_token', 'token', - ].firstWhere((v) => - client.issuer.metadata.responseTypesSupported.contains(v)), + ].firstWhere((v) => client.issuer.metadata.responseTypesSupported.contains(v)), client, state: state, scopes: [ @@ -462,8 +473,8 @@ class Flow { Flow.jwtBearer(Client client) : this._(FlowType.jwtBearer, null, client); - Uri get authenticationUri => client.issuer.metadata.authorizationEndpoint - .replace(queryParameters: _authenticationUriParameters); + Uri get authenticationUri => + client.issuer.metadata.authorizationEndpoint.replace(queryParameters: _authenticationUriParameters); late Map _proofKeyForCodeExchange; @@ -477,14 +488,10 @@ class Flow { 'client_id': client.clientId, 'redirect_uri': redirectUri.toString(), 'state': state - }..addAll( - responseType!.split(' ').contains('id_token') ? {'nonce': _nonce} : {}); + }..addAll(responseType!.split(' ').contains('id_token') ? {'nonce': _nonce} : {}); if (type == FlowType.proofKeyForCodeExchange) { - v.addAll({ - 'code_challenge_method': 'S256', - 'code_challenge': _proofKeyForCodeExchange['code_challenge'] - }); + v.addAll({'code_challenge_method': 'S256', 'code_challenge': _proofKeyForCodeExchange['code_challenge']}); } return v; } @@ -506,8 +513,7 @@ class Flow { 'code': code, 'redirect_uri': redirectUri.toString(), 'client_id': client.clientId, - if (client.clientSecret != null) - 'client_secret': client.clientSecret, + if (client.clientSecret != null) 'client_secret': client.clientSecret, 'code_verifier': _proofKeyForCodeExchange['code_verifier'] }, client: client.httpClient); @@ -522,15 +528,10 @@ class Flow { }, client: client.httpClient); } else if (methods.contains('client_secret_basic')) { - var h = - base64.encode('${client.clientId}:${client.clientSecret}'.codeUnits); + var h = base64.encode('${client.clientId}:${client.clientSecret}'.codeUnits); json = await http.post(client.issuer.tokenEndpoint, headers: {'authorization': 'Basic $h'}, - body: { - 'grant_type': 'authorization_code', - 'code': code, - 'redirect_uri': redirectUri.toString() - }, + body: {'grant_type': 'authorization_code', 'code': code, 'redirect_uri': redirectUri.toString()}, client: client.httpClient); } else { throw UnsupportedError('Unknown auth methods: $methods'); @@ -541,23 +542,86 @@ class Flow { /// Login with username and password /// /// Only allowed for [Flow.password] flows. - Future loginWithPassword( - {required String username, required String password}) async { + Future loginWithPassword({required String username, required String password}) async { 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'); @@ -566,12 +630,10 @@ class Flow { var code = response['jwt']; return Credential._(client, await _getToken(code), null); } else if (response.containsKey('code') && - (type == FlowType.proofKeyForCodeExchange || - client.clientSecret != null)) { + (type == FlowType.proofKeyForCodeExchange || client.clientSecret != null)) { var code = response['code']; return Credential._(client, await _getToken(code), null); - } else if (response.containsKey('access_token') || - response.containsKey('id_token')) { + } else if (response.containsKey('access_token') || response.containsKey('id_token')) { return Credential._(client, TokenResponse.fromJson(response), _nonce); } else { return Credential._(client, TokenResponse.fromJson(response), _nonce); @@ -582,8 +644,7 @@ class Flow { String _randomString(int length) { var r = Random.secure(); var chars = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'; - return Iterable.generate(length, (_) => chars[r.nextInt(chars.length)]) - .join(); + return Iterable.generate(length, (_) => chars[r.nextInt(chars.length)]).join(); } /// An exception thrown when a response is received in the openid error format. @@ -614,16 +675,14 @@ class OpenIdException implements Exception { 'The User Questioning Request is not valid. The request is missing a required parameter, includes an unsupported parameter value (other than grant type), repeats a parameter, includes multiple credentials, utilizes more than one mechanism for authenticating the client, or is otherwise malformed.', 'no_suitable_method': 'There is no Questioning Method suitable with the User Questioning Request. The OP can use this error code when it does not implement mechanisms suitable for the wished AMR or ACR.', - 'timeout': - 'The Questioned User did not answer in the allowed period of time.', + 'timeout': 'The Questioned User did not answer in the allowed period of time.', 'unauthorized': 'The Client is not authorized to use the User Questioning API or did not send a valid Access Token.', 'unknown_user': 'The Questioned User mentioned in the user_id attribute of the User Questioning Request is unknown.', 'unreachable_user': 'The Questioned User mentioned in the User Questioning Request (either in the Access Token or in the user_id attribute) is unreachable. The OP can use this error when it does not have a reachability identifier (e.g. MSISDN) for the Question User or when the reachability identifier is not operational (e.g. unsubscribed MSISDN).', - 'user_refused_to_answer': - 'The Questioned User refused to make a statement to the question.', + 'user_refused_to_answer': 'The Questioned User refused to make a statement to the question.', 'interaction_required': 'The Authorization Server requires End-User interaction of some form to proceed. This error MAY be returned when the prompt parameter value in the Authentication Request is none, but the Authentication Request cannot be completed without displaying a user interface for End-User interaction.', 'login_required': @@ -632,18 +691,12 @@ class OpenIdException implements Exception { 'The End-User is REQUIRED to select a session at the Authorization Server. The End-User MAY be authenticated at the Authorization Server with different associated accounts, but the End-User did not select a session. This error MAY be returned when the prompt parameter value in the Authentication Request is none, but the Authentication Request cannot be completed without displaying a user interface to prompt for a session to use.', 'consent_required': 'The Authorization Server requires End-User consent. This error MAY be returned when the prompt parameter value in the Authentication Request is none, but the Authentication Request cannot be completed without displaying a user interface for End-User consent.', - 'invalid_request_uri': - 'The request_uri in the Authorization Request returns an error or contains invalid data.', - 'invalid_request_object': - 'The request parameter contains an invalid Request Object.', - 'request_not_supported': - 'The OP does not support use of the request parameter', - 'request_uri_not_supported': - 'The OP does not support use of the request_uri parameter', - 'registration_not_supported': - 'The OP does not support use of the registration parameter', - 'invalid_redirect_uri': - 'The value of one or more redirect_uris is invalid.', + 'invalid_request_uri': 'The request_uri in the Authorization Request returns an error or contains invalid data.', + 'invalid_request_object': 'The request parameter contains an invalid Request Object.', + 'request_not_supported': 'The OP does not support use of the request parameter', + 'request_uri_not_supported': 'The OP does not support use of the request_uri parameter', + 'registration_not_supported': 'The OP does not support use of the registration parameter', + 'invalid_redirect_uri': 'The value of one or more redirect_uris is invalid.', 'invalid_client_metadata': 'The value of one of the Client Metadata fields is invalid and the server has rejected this request. Note that an Authorization Server MAY choose to substitute a valid value for any requested parameter of a Client\'s Metadata.', }; @@ -651,13 +704,17 @@ class OpenIdException implements Exception { /// Thrown when trying to get a token, but the token endpoint is missing from /// the issuer metadata const OpenIdException.missingTokenEndpoint() - : this._('missing_token_endpoint', - 'The issuer metadata does not contain a token endpoint.'); + : 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.'); const OpenIdException._(this.code, this.message) : uri = null; - OpenIdException(this.code, String? message, [this.uri]) - : message = message ?? _defaultMessages[code!]; + OpenIdException(this.code, String? message, [this.uri]) : message = message ?? _defaultMessages[code!]; @override String toString() => 'OpenIdException($code): $message'; From 67168a0a1cc260d7e9a0f2558667397e44ed95bc Mon Sep 17 00:00:00 2001 From: Luca Fabbri Date: Tue, 23 Jan 2024 09:09:03 +0100 Subject: [PATCH 6/6] chore: restore line length as original project --- lib/src/model/metadata.dart | 36 ++++++--- lib/src/openid.dart | 141 +++++++++++++++++++++++++----------- 2 files changed, 121 insertions(+), 56 deletions(-) diff --git a/lib/src/model/metadata.dart b/lib/src/model/metadata.dart index bba7ea6..e918d70 100644 --- a/lib/src/model/metadata.dart +++ b/lib/src/model/metadata.dart @@ -15,7 +15,8 @@ class OpenIdProviderMetadata extends JsonObject { Uri? get userinfoEndpoint => getTyped('userinfo_endpoint'); /// URL of the OP's Device Authorization Endpoint - Uri? get deviceAuthorizationEndpoint => getTyped('device_authorization_endpoint'); + Uri? get deviceAuthorizationEndpoint => + getTyped('device_authorization_endpoint'); /// URL of the OP's JSON Web Key Set document. /// @@ -29,13 +30,16 @@ class OpenIdProviderMetadata extends JsonObject { List? get scopesSupported => getTypedList('scopes_supported'); /// A list of the OAuth 2.0 `response_type` values that this OP supports. - List get responseTypesSupported => getTypedList('response_types_supported')!; + List get responseTypesSupported => + getTypedList('response_types_supported')!; /// A list of the OAuth 2.0 `response_mode` values that this OP supports. - List? get responseModesSupported => getTypedList('response_modes_supported'); + List? get responseModesSupported => + getTypedList('response_modes_supported'); /// A list of the OAuth 2.0 Grant Type values that this OP supports. - List? get grantTypesSupported => getTypedList('grant_types_supported'); + List? get grantTypesSupported => + getTypedList('grant_types_supported'); /// A list of the Authentication Context Class References that this OP supports. List? get acrValuesSupported => getTypedList('acr_values_supported'); @@ -43,7 +47,8 @@ class OpenIdProviderMetadata extends JsonObject { /// A list of the Subject Identifier types that this OP supports. /// /// Valid types include `pairwise` and `public`. - List get subjectTypesSupported => getTypedList('subject_types_supported')!; + List get subjectTypesSupported => + getTypedList('subject_types_supported')!; /// A list of the JWS signing algorithms (`alg` values) supported by the OP for /// the ID Token to encode the Claims in a JWT. @@ -121,13 +126,15 @@ class OpenIdProviderMetadata extends JsonObject { getTypedList('token_endpoint_auth_signing_alg_values_supported'); /// A list of the display parameter values that the OpenID Provider supports. - List? get displayValuesSupported => getTypedList('display_values_supported'); + List? get displayValuesSupported => + getTypedList('display_values_supported'); /// A list of the Claim Types that the OpenID Provider supports. /// /// Values defined by the specification are `normal`, `aggregated`, and /// `distributed`. If omitted, the implementation supports only `normal` Claims. - List? get claimTypesSupported => getTypedList('claim_types_supported'); + List? get claimTypesSupported => + getTypedList('claim_types_supported'); /// A list of the Claim Names of the Claims that the OpenID Provider MAY be /// able to supply values for. @@ -143,23 +150,28 @@ class OpenIdProviderMetadata extends JsonObject { /// Languages and scripts supported for values in Claims being returned. /// /// Not all languages and scripts are necessarily supported for all Claim values. - List? get claimsLocalesSupported => getTypedList('claims_locales_supported'); + List? get claimsLocalesSupported => + getTypedList('claims_locales_supported'); /// Languages and scripts supported for the user interface. List? get uiLocalesSupported => getTypedList('ui_locales_supported'); /// `true` when the OP supports use of the `claims` parameter. - bool get claimsParameterSupported => this['claims_parameter_supported'] ?? false; + bool get claimsParameterSupported => + this['claims_parameter_supported'] ?? false; /// `true` when the OP supports use of the `request` parameter. - bool get requestParameterSupported => this['request_parameter_supported'] ?? false; + bool get requestParameterSupported => + this['request_parameter_supported'] ?? false; /// `true` when the OP supports use of the `request_uri` parameter. - bool get requestUriParameterSupported => this['request_uri_parameter_supported'] ?? true; + bool get requestUriParameterSupported => + this['request_uri_parameter_supported'] ?? true; /// `true` when the OP requires any `request_uri` values used to be /// pre-registered using the request_uris registration parameter. - bool get requireRequestUriRegistration => this['require_request_uri_registration'] ?? false; + bool get requireRequestUriRegistration => + this['require_request_uri_registration'] ?? false; /// URL that the OpenID Provider provides to the person registering the Client /// to read about the OP's requirements on how the Relying Party can use the diff --git a/lib/src/openid.dart b/lib/src/openid.dart index 4c41d3c..3e95fc0 100644 --- a/lib/src/openid.dart +++ b/lib/src/openid.dart @@ -25,7 +25,9 @@ class Issuer { /// Creates an issuer from its metadata. Issuer(this.metadata, {this.claimsMap = const {}}) - : _keyStore = metadata.jwksUri == null ? JsonWebKeyStore() : (JsonWebKeyStore()..addKeySetUrl(metadata.jwksUri!)); + : _keyStore = metadata.jwksUri == null + ? JsonWebKeyStore() + : (JsonWebKeyStore()..addKeySetUrl(metadata.jwksUri!)); /// Url of the facebook issuer. /// @@ -40,12 +42,14 @@ class Issuer { static final Uri yahoo = Uri.parse('https://api.login.yahoo.com'); /// Url of the microsoft issuer. - static final Uri microsoft = Uri.parse('https://login.microsoftonline.com/common'); + static final Uri microsoft = + Uri.parse('https://login.microsoftonline.com/common'); /// Url of the salesforce issuer. static final Uri salesforce = Uri.parse('https://login.salesforce.com'); - static Uri firebase(String id) => Uri.parse('https://securetoken.google.com/$id'); + static Uri firebase(String id) => + Uri.parse('https://securetoken.google.com/$id'); static final Map _discoveries = { facebook: Issuer(OpenIdProviderMetadata.fromJson({ @@ -140,7 +144,8 @@ class Client { Client(this.issuer, this.clientId, {this.clientSecret, this.httpClient}); - static Future forIdToken(String idToken, {http.Client? httpClient}) async { + static Future forIdToken(String idToken, + {http.Client? httpClient}) async { var token = JsonWebToken.unverified(idToken); var claims = OpenIdClaims.fromJson(token.claims.toJson()); var issuer = await Issuer.discover(claims.issuer, httpClient: httpClient); @@ -167,7 +172,8 @@ class Client { 'refresh_token': refreshToken, 'id_token': idToken, if (expiresIn != null) 'expires_in': expiresIn.inSeconds, - if (expiresAt != null) 'expires_at': expiresAt.millisecondsSinceEpoch ~/ 1000 + if (expiresAt != null) + 'expires_at': expiresAt.millisecondsSinceEpoch ~/ 1000 }), null); } @@ -179,7 +185,8 @@ class DeviceCode { final String verificationUri; final String verificationUriComplete; - DeviceCode(this.deviceCode, this.expiredIn, this.userCode, this.verificationUri, this.verificationUriComplete); + DeviceCode(this.deviceCode, this.expiredIn, this.userCode, + this.verificationUri, this.verificationUriComplete); factory DeviceCode.fromJson(Map json) { return DeviceCode( @@ -197,7 +204,8 @@ class Credential { final Client client; final String? nonce; - final StreamController _onTokenChanged = StreamController.broadcast(); + final StreamController _onTokenChanged = + StreamController.broadcast(); Credential._(this.client, this._token, this.nonce); @@ -261,13 +269,15 @@ class Credential { Uri? generateLogoutUrl({Uri? redirectUri, String? state}) { return client.issuer.metadata.endSessionEndpoint?.replace(queryParameters: { 'id_token_hint': _token.idToken.toCompactSerialization(), - if (redirectUri != null) 'post_logout_redirect_uri': redirectUri.toString(), + if (redirectUri != null) + 'post_logout_redirect_uri': redirectUri.toString(), if (state != null) 'state': state }); } http.Client createHttpClient([http.Client? baseClient]) => - http.AuthorizedClient(baseClient ?? client.httpClient ?? http.Client(), this); + http.AuthorizedClient( + baseClient ?? client.httpClient ?? http.Client(), this); Future _get(uri) async { return http.get(uri, client: createHttpClient()); @@ -279,13 +289,16 @@ class Credential { IdToken get idToken => _token.idToken; - Stream validateToken({bool validateClaims = true, bool validateExpiry = true}) async* { + Stream validateToken( + {bool validateClaims = true, bool validateExpiry = true}) async* { var keyStore = JsonWebKeyStore(); var jwksUri = client.issuer.metadata.jwksUri; if (jwksUri != null) { keyStore.addKeySetUrl(jwksUri); } - if (!await idToken.verify(keyStore, allowedArguments: client.issuer.metadata.idTokenSigningAlgValuesSupported)) { + if (!await idToken.verify(keyStore, + allowedArguments: + client.issuer.metadata.idTokenSigningAlgValuesSupported)) { yield JoseException('Could not verify token signature'); } @@ -295,7 +308,9 @@ class Credential { issuer: client.issuer.metadata.issuer, clientId: client.clientId, nonce: nonce) - .where((e) => validateExpiry || !(e is JoseException && e.message.startsWith('JWT expired.')))); + .where((e) => + validateExpiry || + !(e is JoseException && e.message.startsWith('JWT expired.')))); } String? get refreshToken => _token.refreshToken; @@ -303,7 +318,8 @@ class Credential { Future getTokenResponse([bool forceRefresh = false]) async { if (!forceRefresh && _token.accessToken != null && - (_token.expiresAt == null || _token.expiresAt!.isAfter(DateTime.now()))) { + (_token.expiresAt == null || + _token.expiresAt!.isAfter(DateTime.now()))) { return _token; } if (_token.accessToken == null && _token.refreshToken == null) { @@ -330,14 +346,19 @@ class Credential { /// used to update the token manually, e.g. when no refresh token is available /// and the token is updated by other means. void updateToken(Map json) { - _token = TokenResponse.fromJson({'refresh_token': _token.refreshToken, ...json}); + _token = + TokenResponse.fromJson({'refresh_token': _token.refreshToken, ...json}); _onTokenChanged.add(_token); } Credential.fromJson(Map json, {http.Client? httpClient}) : this._( - Client(Issuer(OpenIdProviderMetadata.fromJson((json['issuer'] as Map).cast())), json['client_id'], - clientSecret: json['client_secret'], httpClient: httpClient), + Client( + Issuer(OpenIdProviderMetadata.fromJson( + (json['issuer'] as Map).cast())), + json['client_id'], + clientSecret: json['client_secret'], + httpClient: httpClient), TokenResponse.fromJson((json['token'] as Map).cast()), json['nonce']); @@ -409,12 +430,17 @@ class Flow { } var verifier = codeVerifier ?? _randomString(50); - var challenge = - base64Url.encode(SHA256Digest().process(Uint8List.fromList(verifier.codeUnits))).replaceAll('=', ''); - _proofKeyForCodeExchange = {'code_verifier': verifier, 'code_challenge': challenge}; + var challenge = base64Url + .encode(SHA256Digest().process(Uint8List.fromList(verifier.codeUnits))) + .replaceAll('=', ''); + _proofKeyForCodeExchange = { + 'code_verifier': verifier, + 'code_challenge': challenge + }; } - Flow.device(Client client, {List scopes = const ['openid', 'profile', 'email']}) + Flow.device(Client client, + {List scopes = const ['openid', 'profile', 'email']}) : this._( FlowType.device, '', @@ -427,7 +453,8 @@ class Flow { /// This flow can be used for active authentication by highly-trusted /// applications. Call [Flow.loginWithPassword] to authenticate a user with /// their username and password. - Flow.password(Client client, {List scopes = const ['openid', 'profile', 'email']}) + Flow.password(Client client, + {List scopes = const ['openid', 'profile', 'email']}) : this._( FlowType.password, '', @@ -480,7 +507,8 @@ class Flow { 'id_token token', 'id_token', 'token', - ].firstWhere((v) => client.issuer.metadata.responseTypesSupported.contains(v)), + ].firstWhere((v) => + client.issuer.metadata.responseTypesSupported.contains(v)), client, state: state, scopes: [ @@ -496,8 +524,8 @@ class Flow { Flow.jwtBearer(Client client) : this._(FlowType.jwtBearer, null, client); - Uri get authenticationUri => - client.issuer.metadata.authorizationEndpoint.replace(queryParameters: _authenticationUriParameters); + Uri get authenticationUri => client.issuer.metadata.authorizationEndpoint + .replace(queryParameters: _authenticationUriParameters); late Map _proofKeyForCodeExchange; @@ -511,10 +539,14 @@ class Flow { 'client_id': client.clientId, 'redirect_uri': redirectUri.toString(), 'state': state - }..addAll(responseType!.split(' ').contains('id_token') ? {'nonce': _nonce} : {}); + }..addAll( + responseType!.split(' ').contains('id_token') ? {'nonce': _nonce} : {}); if (type == FlowType.proofKeyForCodeExchange) { - v.addAll({'code_challenge_method': 'S256', 'code_challenge': _proofKeyForCodeExchange['code_challenge']}); + v.addAll({ + 'code_challenge_method': 'S256', + 'code_challenge': _proofKeyForCodeExchange['code_challenge'] + }); } return v; } @@ -536,7 +568,8 @@ class Flow { 'code': code, 'redirect_uri': redirectUri.toString(), 'client_id': client.clientId, - if (client.clientSecret != null) 'client_secret': client.clientSecret, + if (client.clientSecret != null) + 'client_secret': client.clientSecret, 'code_verifier': _proofKeyForCodeExchange['code_verifier'] }, client: client.httpClient); @@ -551,10 +584,15 @@ class Flow { }, client: client.httpClient); } else if (methods.contains('client_secret_basic')) { - var h = base64.encode('${client.clientId}:${client.clientSecret}'.codeUnits); + var h = + base64.encode('${client.clientId}:${client.clientSecret}'.codeUnits); json = await http.post(client.issuer.tokenEndpoint, headers: {'authorization': 'Basic $h'}, - body: {'grant_type': 'authorization_code', 'code': code, 'redirect_uri': redirectUri.toString()}, + body: { + 'grant_type': 'authorization_code', + 'code': code, + 'redirect_uri': redirectUri.toString() + }, client: client.httpClient); } else { throw UnsupportedError('Unknown auth methods: $methods'); @@ -565,7 +603,8 @@ class Flow { /// Login with username and password /// /// Only allowed for [Flow.password] flows. - Future loginWithPassword({required String username, required String password}) async { + Future loginWithPassword( + {required String username, required String password}) async { if (type != FlowType.password) { throw UnsupportedError('Flow is not password'); } @@ -583,7 +622,8 @@ class Flow { return Credential._(client, TokenResponse.fromJson(json), null); } - Future getDeviceCode(Function(Credential? credentials) callback) async { + Future getDeviceCode( + Function(Credential? credentials) callback) async { if (type != FlowType.device) { throw UnsupportedError('Flow is not password'); } @@ -653,10 +693,12 @@ class Flow { var code = response['jwt']; return Credential._(client, await _getToken(code), null); } else if (response.containsKey('code') && - (type == FlowType.proofKeyForCodeExchange || client.clientSecret != null)) { + (type == FlowType.proofKeyForCodeExchange || + client.clientSecret != null)) { var code = response['code']; return Credential._(client, await _getToken(code), null); - } else if (response.containsKey('access_token') || response.containsKey('id_token')) { + } else if (response.containsKey('access_token') || + response.containsKey('id_token')) { return Credential._(client, TokenResponse.fromJson(response), _nonce); } else { return Credential._(client, TokenResponse.fromJson(response), _nonce); @@ -667,7 +709,8 @@ class Flow { String _randomString(int length) { var r = Random.secure(); var chars = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'; - return Iterable.generate(length, (_) => chars[r.nextInt(chars.length)]).join(); + return Iterable.generate(length, (_) => chars[r.nextInt(chars.length)]) + .join(); } /// An exception thrown when a response is received in the openid error format. @@ -698,14 +741,16 @@ class OpenIdException implements Exception { 'The User Questioning Request is not valid. The request is missing a required parameter, includes an unsupported parameter value (other than grant type), repeats a parameter, includes multiple credentials, utilizes more than one mechanism for authenticating the client, or is otherwise malformed.', 'no_suitable_method': 'There is no Questioning Method suitable with the User Questioning Request. The OP can use this error code when it does not implement mechanisms suitable for the wished AMR or ACR.', - 'timeout': 'The Questioned User did not answer in the allowed period of time.', + 'timeout': + 'The Questioned User did not answer in the allowed period of time.', 'unauthorized': 'The Client is not authorized to use the User Questioning API or did not send a valid Access Token.', 'unknown_user': 'The Questioned User mentioned in the user_id attribute of the User Questioning Request is unknown.', 'unreachable_user': 'The Questioned User mentioned in the User Questioning Request (either in the Access Token or in the user_id attribute) is unreachable. The OP can use this error when it does not have a reachability identifier (e.g. MSISDN) for the Question User or when the reachability identifier is not operational (e.g. unsubscribed MSISDN).', - 'user_refused_to_answer': 'The Questioned User refused to make a statement to the question.', + 'user_refused_to_answer': + 'The Questioned User refused to make a statement to the question.', 'interaction_required': 'The Authorization Server requires End-User interaction of some form to proceed. This error MAY be returned when the prompt parameter value in the Authentication Request is none, but the Authentication Request cannot be completed without displaying a user interface for End-User interaction.', 'login_required': @@ -714,12 +759,18 @@ class OpenIdException implements Exception { 'The End-User is REQUIRED to select a session at the Authorization Server. The End-User MAY be authenticated at the Authorization Server with different associated accounts, but the End-User did not select a session. This error MAY be returned when the prompt parameter value in the Authentication Request is none, but the Authentication Request cannot be completed without displaying a user interface to prompt for a session to use.', 'consent_required': 'The Authorization Server requires End-User consent. This error MAY be returned when the prompt parameter value in the Authentication Request is none, but the Authentication Request cannot be completed without displaying a user interface for End-User consent.', - 'invalid_request_uri': 'The request_uri in the Authorization Request returns an error or contains invalid data.', - 'invalid_request_object': 'The request parameter contains an invalid Request Object.', - 'request_not_supported': 'The OP does not support use of the request parameter', - 'request_uri_not_supported': 'The OP does not support use of the request_uri parameter', - 'registration_not_supported': 'The OP does not support use of the registration parameter', - 'invalid_redirect_uri': 'The value of one or more redirect_uris is invalid.', + 'invalid_request_uri': + 'The request_uri in the Authorization Request returns an error or contains invalid data.', + 'invalid_request_object': + 'The request parameter contains an invalid Request Object.', + 'request_not_supported': + 'The OP does not support use of the request parameter', + 'request_uri_not_supported': + 'The OP does not support use of the request_uri parameter', + 'registration_not_supported': + 'The OP does not support use of the registration parameter', + 'invalid_redirect_uri': + 'The value of one or more redirect_uris is invalid.', 'invalid_client_metadata': 'The value of one of the Client Metadata fields is invalid and the server has rejected this request. Note that an Authorization Server MAY choose to substitute a valid value for any requested parameter of a Client\'s Metadata.', }; @@ -727,7 +778,8 @@ class OpenIdException implements Exception { /// Thrown when trying to get a token, but the token endpoint is missing from /// the issuer metadata const OpenIdException.missingTokenEndpoint() - : this._('missing_token_endpoint', 'The issuer metadata does not contain a token endpoint.'); + : 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 @@ -737,7 +789,8 @@ class OpenIdException implements Exception { const OpenIdException._(this.code, this.message) : uri = null; - OpenIdException(this.code, String? message, [this.uri]) : message = message ?? _defaultMessages[code!]; + OpenIdException(this.code, String? message, [this.uri]) + : message = message ?? _defaultMessages[code!]; @override String toString() => 'OpenIdException($code): $message';