diff --git a/CHANGELOG.md b/CHANGELOG.md index cfe0468..36fefa3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ + +## 2.0.0-alpha +- Added support for implementing OAuth1 servers. +- Implemented RSA-SHA1 signature method. +- Fixed support for signing parameters with multiple values. +- Fixed percent encoding to generate correct signature base strings. + ## 1.0.3 - Confirm compatibility with package http 0.12.0 diff --git a/README.md b/README.md index d05fd73..a91188a 100644 --- a/README.md +++ b/README.md @@ -1,21 +1,64 @@ OAuth1 -=========== +====== [![Build Status](https://travis-ci.org/nbspou/dart-oauth1.svg?branch=fork/nbspou)](https://travis-ci.org/nbspou/dart-oauth1) -"[RFC 5849: The OAuth 1.0 Protocol][rfc5849]" client implementation for dart +OAuth1 library for implementing OAuth1 clients and OAuth1 servers. -Usage ------ +## Supported features -Add to `pubspec.yaml`: +### OAuth 1.0a protocol -```yaml -dependencies: - oauth1: ^1.0.4 -``` +This library supports OAuth 1.0 as defined by [RFC 5849: The OAuth 1.0 +Protocol][rfc5849]. + +The RFC 5849 was published in 2010. It addresses errata on the +_OAuth Core 1.0 Revision A_ (also known as OAuth1a) that was published +in 2009. That was a revision of the earlier 2007 specification. This +library does not support OAuth 2.0. + +### Signature methods + +All the signature methods defined in RFC 5849 are supported: + +- HMAC-SHA1; +- RSA-SHA1; and +- PLAINTEXT + +### Three-legged-OAuth and two-legged-OAuth + +This library can be used to implement three-legged-OAuth, as defined +in the first part of RFC 5849. This is where there are three parties +involved: the client, the resource owner and the server. + +1. The _client_ obtains a _temporary credential_ from the _server_; +2. The _resource owner_ authorizes the _server_ to grant the client's + access request (as identified by the _temporary credential_); +3. The _client_ uses the _temporary credential_ to request a + _token credential_ from the _server_; and +4. The _client_ accesses protected resources by presenting the _token + credential_. + +It can also be used to implement two-legged-OAuth, which only +involves two parties: the client and the server. The client sends a +single request for the protected resource and the server responds with +it. + +## Usage + +### OAuth1 client -Please use like below. +This library can be used to sign an OAuth1 request. The signed OAuth1 +protocol parameters can then be added to a HTTP request and sent to an +OAuth1 server. + +Usually, the OAuth1 protocol parameters are sent in a HTTP +"Authorization" header. This library provides a method to format the +parameters for that header. But (less commonly) the parameters can +also be sent as URI query parameters and/or in the body of the HTTP +request. + +Here is an example of an OAuth1 client performing three-legged-OAuth: ```dart import 'dart:io'; @@ -63,9 +106,36 @@ void main() { print("Your screen name is " + res.optionalParameters['screen_name']); }); } - ``` -In addition, You should save and load the granted token credentials from your drive. Of cource, you don't need to authorize when you did it. +Once the access token has been obtained, it may be used for multiple +requests. The client may find it useful to save the access token for +future use, so it does not have to go through the three-legged-OAuth +process again. But the usefulness of that will depend on the policy of +the server and if access tokens expire or not. + +An expanded version of the above code appears in the +_example_client.dart_ example. + +### OAuth1 server + +An OAuth1 server is a HTTP server that implements the endpoints for +processing OAuth1 requests and for accessing the protected resources. + +If it implements the three-legged-OAuth protocol, it needs to issues +and manages both temporary credentials and access tokens. If it +implements the two-legged-OAuth protocol, it only needs to implement +the endpoints for the protected resources (there are no _temporary +credentials_ nor _access tokens_ involved). + + +This library can be used to parse the information in an OAuth1 HTTP +request and validate the signature. If the signature is valid, then +the server can use the information from the request to perform the +task of the endpoint. + +See the _example_server.dart_ for an example of using the library to +create a three-legged-OAuth1 server. + [rfc5849]: http://tools.ietf.org/html/rfc5849 diff --git a/analysis_options.yaml b/analysis_options.yaml index 214a994..283317e 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -119,7 +119,6 @@ linter: # - sort_constructors_first - sort_pub_dependencies - sort_unnamed_constructors_first - - super_goes_last - test_types_in_equals - throw_in_finally # - type_annotate_public_apis # subset of always_specify_types @@ -142,4 +141,4 @@ linter: # - use_string_buffers # has false positives: https://github.com/dart-lang/sdk/issues/34182 # - use_to_and_as_if_applicable # has false positives, so we prefer to catch this by code-review - valid_regexps - # - void_checks # not yet tested \ No newline at end of file + # - void_checks # not yet tested diff --git a/example/README.md b/example/README.md new file mode 100644 index 0000000..01ceebf --- /dev/null +++ b/example/README.md @@ -0,0 +1,137 @@ +OAuth1 Examples +=============== + +## Introduction + +These examples demonstrate the use of the oauth1 library to +implement an OAuth client and an OAuth server. + +## Running the examples + +1. Run the example OAuth server: + + dart example_server.dart + +2. In a different terminal, run the client with the URL of the server: + + dart example_client.dart -s http://localhost:8080 + +3. Open the URL printed by the client and authorize the client using + the username "armstrong" and password of "password". + +4. Type the PIN from the Web page into the client. + +5. The client should print out a message indicating it has successfully + accessed the protected resource. + +## Changing behaviour + +### Using other signing methods + +The example client's default signature method is HMAC-SHA1. +Use the following options to change the signature method: + +- `--rsa-sha1` (`-r`) to use the RSA-SHA1 signature method; +- `--plaintext` (`-p`) to use the PLAINTEXT signature method. + +The server is capable of using any of the signing methods, as long as +the client credentials it has are suitable. That is, it has a shared +secret for HMAC-SHA1 and PLAINTEXT, or it has an RSA public key for +RSA-SHA1. + +### Using other credentials + +The client and server both have hard-coded credentials. The default +hard-coded credentials are suitable for all three signature methods. + +#### Server + +For the server to know about other client credentials, run the server +and provide credential files as additional arguments. Multiple files +can be provided. For example, + + dart example_server.dart tester1.secret tester2.public + +#### Client + +For the client to use another client credential, specify the +credentials file using the `--credentials` (`-c`) option: + +```sh +dart example_client.dart -s http://localhost:8080 -c tester2.private --rsa-sha1 +``` + +The client identity can also be specified using the `--client` option, +otherwise it must be specified inside the credentials file. Note: +this only applies to the example client: the credentials file for the +example server must include the client identity (since more than one +credentials files can be registered with the server). + +#### Credentials file + +A credentials file can contain: + +- a user friendly name for the client (as "name"); +- client identifier (as "oauth_consumer_key"); +- shared secret (as "secret"); +- PEM formatted RSA private key; and/or +- PEM formatted RSA public key. + +See the example tester credentials files for the expected syntax. + +Only PEM formatted RSA public/private keys are recognised. + +Note: the client credentials file for the server must not contain an +an RSA private key, since the OAuth server should not have the +client's private key. + +### Showing more information + +Use the `--verbose` (`-v`) option on the example client and example +server. + +### Testing without the manual authorization step + +Both the server and client have a `--backdoor` (`-B`) option to make +them easier to test. + +Enabling it on both client and server causes the client to +automatically submit a backdoor value for the PIN verifier, which +causes the server to automatically approve the request (as if a +_resource owner_ had visited the Web page and approved the client's +request) and continue to accept the verifier as correct. This avoids +the need to interact with the Web browser to obtain the PIN. + +This feature is for testing only. Obviously, a production OAuth server +should not have a backdoor. + +### Two-legged-OAuth + +The default behaviour is to perform three-legged-OAuth. + +To use two-legged-OAuth, run both the example server and example +client with the `--two-legged-oauth` (`-2`) option. + +### Testing against other OAuth servers + +The example client has options to customise the server endpoints for +the OAuth1 protocol. + +The `--server` (`-s`) is a short-hand for setting all three OAuth1 +protocol endpoints plus one protected resource endpoint. It assumes +hard-coded paths under that URL. Those hard-coded paths match those +hard-coded in the example server as well as the Twitter API +(i.e. _/oauth/request_token_, _/oauth/authorize_, +_/oauth/access_token_ and _/1.1/statuses/home_timeline.json_). + +If those hard-coded parths are not suitable, the full endpoint URLs +can be specified using these options: + +- `--temp-uri` (`-T`) for the temporary credential requset URI; +- `--auth-uri` (`-R`) for the resource owner authorization URI; +- `--token-uri` (`-A`) for the (access) token request URI; and +- one or more protected resource URIs as additional arguments. + +The default behaviour, if none of these options are used, is to +contact the Twitter API. + diff --git a/example/example_client.dart b/example/example_client.dart new file mode 100644 index 0000000..519ba3b --- /dev/null +++ b/example/example_client.dart @@ -0,0 +1,539 @@ +/// Example OAuth1a client. +/// +/// This client has works with the Twitter OAuth 1a API, using 3-legged OAuth +/// with HMAC-SHA1. +/// https://developer.twitter.com/en/docs/basics/authentication/overview +/// https://tools.ietf.org/html/rfc5849 +/// +/// It also works with the "example_server.dart" program. +/// +/// This client uses an out-of-band mechanism to obtain the verifier (i.e. it +/// asks the resource owner to type in the value) since it does not implement +/// a callback for the server to redirect the browser to. + +import 'dart:async'; +import 'dart:io'; + +import 'package:args/args.dart'; +import 'package:encrypt/encrypt.dart'; +import 'package:oauth1/oauth1.dart' as oauth1; +import 'package:pointycastle/asymmetric/api.dart'; + +// Dart Linter overrides +// ignore_for_file: always_specify_types + +//################################################################ +// Constants + +// These values have been registered with Twitter as the "dart-oauth1-test" app +// and also hard-coded into the example_server.dart. Therefore, by default this +// client can be used to communicate with Twitter or the example server. But +// other client credentials can be used, by specifying them via the command +// line. + +const String defaultServerUri = 'https://api.twitter.com'; + +const String tmpCredentialRequestUrl = '/oauth/request_token'; +const String resourceOwnerAuthUrl = '/oauth/authorize'; +const String tokenRequestUrl = '/oauth/access_token'; + +const String restrictedResourceUrl = '/1.1/statuses/home_timeline.json'; + +// Default client credentials + +const defaultClientIdentity = 'LLDeVY0ySvjoOVmJ2XgBItvTV'; +const defaultSecret = 'JmEpkWXXmY7BYoQor5AyR84BD2BiN47GIBUPXn3bopZqodJ0MV'; + +const defaultPrivateKey = ''' +-----BEGIN RSA PRIVATE KEY----- +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 +-----END RSA PRIVATE KEY----- +'''; + +//################################################################ +// Command line parsing + +class Arguments { + Arguments(List args) { + programName = Platform.script.pathSegments.last.replaceAll('.dart', ''); + + final parser = ArgParser(allowTrailingOptions: true) + ..addOption('server', + abbr: 's', help: 'base server URI', defaultsTo: defaultServerUri) + ..addOption('credentials', abbr: 'c', help: 'client credentials file') + ..addOption('client', + abbr: 'C', + help: 'client identity (a.k.a. oauth_consumer_key or API key)') + ..addFlag('rsa-sha1', + abbr: 'r', + help: 'use RSA-SHA1 signature method (default: HMAC-SHA1)', + negatable: false) + ..addFlag('plaintext', + abbr: 'p', + help: 'use PLAINTEXT signature method (default: HMAC-SHA1)', + negatable: false) + ..addOption('temp-uri', + abbr: 'T', help: 'URI for temporary credential request *') + ..addOption('auth-uri', + abbr: 'R', help: 'URI for resource owner authorization *') + ..addOption('token-uri', + abbr: 'A', help: 'URI for access token request *') + ..addFlag('two-legged-oauth', + abbr: '2', help: 'use 2-legged-OAuth', negatable: false) + ..addFlag('backdoor', + abbr: 'B', + help: 'use backdoor verifier (for example_server only)', + negatable: false) + ..addFlag('debug', + abbr: 'D', help: 'show debug information', negatable: false) + ..addFlag('verbose', + abbr: 'v', help: 'show more information', negatable: false) + ..addFlag('help', + abbr: 'h', help: 'show this help message', negatable: false); + + try { + final results = parser.parse(args); + + programName = results.name ?? programName; + + // Help flag + + if (results['help']) { + print('Usage: $programName [options] {resourceURIs*}\nOptions:'); + print(parser.usage); + print('* defaults to hard-coded URIs relative to the base server URI.'); + exit(0); + } + + // Signature method + + if (results['rsa-sha1']) { + signatureMethod = oauth1.SignatureMethods.rsaSha1; + } else if (results['plaintext']) { + signatureMethod = oauth1.SignatureMethods.plaintext; + } else { + signatureMethod = oauth1.SignatureMethods.hmacSha1; // default + } + + threeLegged = !results['two-legged-oauth']; + + // Credentials file option + + final keyParser = RSAKeyParser(); + + final credentialFile = _stringOption(results, 'credentials'); + if (credentialFile == null) { + clientIdentity = defaultClientIdentity; + sharedSecret = defaultSecret; + privateKey = keyParser.parse(defaultPrivateKey); + } else { + _loadCredentialsFromFile(keyParser, credentialFile); + } + final c = _stringOption(results, 'client'); + if (c != null) { + clientIdentity = c; + } + + useBackdoor = results['backdoor']; + debug = results['debug']; + verbose = results['verbose']; + + final serverUri = _stringOption(results, 'server', defaultServerUri); + + // Use the URIs provided on the command line, or default to values + // which are different paths under the server URI. + + uriTemporaryCredentialRequest = _stringOption( + results, 'temp-uri', '$serverUri$tmpCredentialRequestUrl'); + uriResourceOwnerAuthorization = + _stringOption(results, 'auth-uri', '$serverUri$resourceOwnerAuthUrl'); + uriTokenRequest = + _stringOption(results, 'token-uri', '$serverUri$tokenRequestUrl'); + + if (results.rest.isEmpty) { + // No protected resource URIs provided: use the default + uriProtectedResources = ['$serverUri$restrictedResourceUrl']; + } else { + // Use all the remaining arguments as URIs for protected resources + uriProtectedResources = results.rest; + } + } on FormatException catch (e) { + stderr.write('Usage error: $programName: ${e.message}\n'); + exit(2); + } + } + + //================================================================ + // Members + + String programName; + + String uriTemporaryCredentialRequest; + String uriResourceOwnerAuthorization; + String uriTokenRequest; + List uriProtectedResources; + + oauth1.SignatureMethod signatureMethod; + bool threeLegged; + + String clientIdentity; // client's identity + String sharedSecret; // client's shared secret (for HMAC-SHA1 or PLAINTEXT) + RSAPrivateKey privateKey; // client's private key (for RSA-SHA1) + + bool useBackdoor; + bool debug; + bool verbose; + + //================================================================ + // Methods + + //---------------------------------------------------------------- + + String _stringOption(ArgResults results, String optionName, + [String defaultValue]) { + final Object value = results[optionName]; + if (value is String) { + return value; + } else { + assert(value == null); + return defaultValue; + } + } + + //---------------------------------------------------------------- + /// Loads client credentials from a file. + /// + /// The file may contain zero or more of the following: + /// + /// - client identity (property name "oauth_consumer_key") + /// - shared secret (property name "secret") + /// - RSA private key (must be in PEM format) + /// + /// It is OK for there to be no client identity in the file, since it can + /// alternatively be provided as a command line option. + + void _loadCredentialsFromFile(RSAKeyParser keyParser, String filename) { + try { + // Parse the file + + int mode = 0; // 0=normal, 1=reading-private-key, 2=reading-public-key + StringBuffer buf; + var seenPublicKey = false; + + var lineNum = 0; + for (final line in File(filename).readAsLinesSync()) { + lineNum++; + if (mode == 0) { + // Normal mode + if (line.trim().isEmpty || line.trim().startsWith('#')) { + // comment or blank line: ignore + } else if (line.startsWith('-----BEGIN RSA PRIVATE KEY-----')) { + mode = 1; // start capturing lines + buf = StringBuffer(line)..write('\n'); + } else { + final pair = line.split(':'); + if (pair.length == 2) { + final name = pair[0].trim(); + final value = pair[1].trim(); + switch (name) { + case 'name': + // Names are not used in the client + break; + case 'oauth_consumer_key': + clientIdentity = value; + break; + case 'secret': + sharedSecret = value; + break; + + default: + stderr.write('$filename: $lineNum: unknown name: $name\n'); + exit(1); + } + } else if (line.contains('BEGIN RSA PUBLIC KEY')) { + mode = 2; + } else { + stderr.write('$filename: $lineNum: unexpected line: $line\n'); + exit(1); + } + } + } else if (mode == 1) { + // Reading private key + buf..write(line)..write('\n'); + if (line.startsWith('-----END RSA PRIVATE KEY-----')) { + mode = 0; // finished private key + privateKey = keyParser.parse(buf.toString()); + buf = null; + } + } else if (mode == 2) { + // Reading public key (and discarding it) + if (line.startsWith('-----END RSA PUBLIC KEY-----')) { + mode = 0; // finished public key + seenPublicKey = true; + } + } + } + + if (mode != 0) { + stderr.write('$filename: error: incomplete: missing end of key\n'); + exit(1); + } + + // Check file contained the necessary information + + if (sharedSecret == null && privateKey == null) { + stderr.write('$filename: error: no "secret" or private key in file\n'); + if (seenPublicKey) { + stderr.write(' The file has a public key, but no private key.\n'); + } + exit(1); + } + } catch (e) { + stderr.write('$filename: $e\n'); + exit(1); + } + } +} + +//################################################################ +/// Three-legged-OAuth example. +/// +/// Example of a client performing three-legged-OAuth to obtain an access token, +/// which is then used to access a protected resource. + +Future threeLeggedOAuth( + String uriTemporaryCredentialRequest, + String uriResourceOwnerAuthorization, + String uriTokenRequest, + oauth1.ClientCredentials clientCredentials, + oauth1.SignatureMethod signatureMethod, + Iterable protectedResourceUris, + {bool verbose, + bool useBackdoor, + bool debug}) async { + final oauth1.Platform platform = oauth1.Platform( + uriTemporaryCredentialRequest, + uriResourceOwnerAuthorization, + uriTokenRequest, + signatureMethod); + + final oauth1.Authorization auth = + oauth1.Authorization(clientCredentials, platform); + + //---------------- + // Step 1: request temporary credentials from the server + // + // If this client was a Web application, it would provide a callback URL + // with the request. But in this non-browser example, the request indicates an + // out-of-band ("oob") mechanism will be used. + + final oauth1.AuthorizationResponse res1 = + await auth.requestTemporaryCredentials('oob'); + + if (debug) { + print('OAuth 1a temporary credentials:\n ${res1.credentials}'); + } + + //---------------- + // Step 2: get the resource owner to approve the temporary credentials + // + // If this client was a Web application, it would redirect the browser to + // the server's Resource Owner Authorization endpoint. And if the approval is + // given, the server will redirect the browser to the callback URL - providing + // this client the approval. + // + // In this non-browser example, print out the URL for the user to visit. + // Since the temporary credential uses an out-of-band mechanism, the server + // will provide the user a PIN code which they will have to manually provide + // to this client. + + final String url = + auth.getResourceOwnerAuthorizationURI(res1.credentials.token); + print('Please open this URL in a browser:\n $url'); + + // This client obtains the verifier (PIN) + + String verifier; + + if (!useBackdoor) { + stdout.write('PIN: '); + verifier = stdin.readLineSync(); + if (verifier == null || verifier.isEmpty) { + stderr.write('aborted\n'); + exit(1); + } + } else { + // This is to make it easy to test with the example_client.dart. It uses + // a verifier that automatically makes the temporary token approved, without + // having to visit the Web page. Obviously, a production server should not + // have any backdoors! + verifier = 'backdoor'; + } + + //---------------- + // Step 3: get access token from the server. + // + // Obtains an access token from the server. + + // request token credentials (access tokens) + + final oauth1.AuthorizationResponse res2 = + await auth.requestTokenCredentials(res1.credentials, verifier); + + if (debug) { + print('OAuth 1a access token credentials:\n ${res2.credentials}'); + } + // NOTE: you can get optional values from AuthorizationResponse object + final name = res2.optionalParameters['screen_name']; + print('Access token was authorized by "$name"'); + + //---------------- + // Step 4: use the access token to access protected resources + + _accessProtectedResources( + signatureMethod, clientCredentials, protectedResourceUris, + verbose: verbose, accessToken: res2.credentials); +} + +//---------------- +/// Send HTTP requests for all the protected resource URIs. +/// +/// This method is used by both the three-legged-OAuth (where the [accessToken] +/// is required, and the two-legged-OAuth (where it is not provided). + +Future _accessProtectedResources( + oauth1.SignatureMethod signatureMethod, + oauth1.ClientCredentials clientCredentials, + Iterable protectedResourceUris, + {oauth1.Credentials accessToken, + bool verbose}) async { + final oauth1.Client client = + oauth1.Client(signatureMethod, clientCredentials, accessToken); + + // Send requests to each of the protected resource URIs + + for (final resourceUri in protectedResourceUris) { + final response = await client.get(resourceUri); + + if (verbose) { + print('Body from $resourceUri:\n${response.body}'); + } + + if (HttpStatus.ok <= response.statusCode && response.statusCode < 300) { + print('Success: $resourceUri: status=${response.statusCode}'); + } else { + stderr.write('Error: $resourceUri: status=${response.statusCode}\n'); + exit(1); + } + } +} + +//---------------------------------------------------------------- +/// Two-legged-OAuth example. +/// +/// Example of a client using two-legged-OAuth to access a protected resource. + +Future twoLeggedOAuth( + oauth1.ClientCredentials clientCredentials, + oauth1.SignatureMethod signatureMethod, + Iterable protectedResourceUris, + {bool verbose, + bool debug}) async { + // Send a request using the client credentials to sign the request. + // No token required. + + _accessProtectedResources( + signatureMethod, clientCredentials, protectedResourceUris, + verbose: verbose); +} + +//---------------------------------------------------------------- + +Future main(List arguments) async { + // Process command line arguments + + final args = Arguments(arguments); + + try { + // Define the credentials for this client (i.e. the identity previously + // established by the client with the server, plus the shared secret + // for HMAC-SHA1 or RSA public and private keys for RSA-SHA1). + + final oauth1.ClientCredentials clientCredentials = oauth1.ClientCredentials( + args.clientIdentity, args.sharedSecret, + privateKey: args.privateKey); + + if (args.threeLegged) { + // Define the platform (i.e. the server) + + await threeLeggedOAuth( + args.uriTemporaryCredentialRequest, + args.uriResourceOwnerAuthorization, + args.uriTokenRequest, + clientCredentials, + args.signatureMethod, + args.uriProtectedResources, + verbose: args.verbose, + useBackdoor: args.useBackdoor, + debug: args.debug); + } else { + await twoLeggedOAuth( + clientCredentials, args.signatureMethod, args.uriProtectedResources, + verbose: args.verbose, debug: args.debug); + } + } catch (e) { + if (args.verbose) { + rethrow; + } + stderr.write('Error: $e\n'); + exit(1); + } +} diff --git a/example/example_server.dart b/example/example_server.dart new file mode 100644 index 0000000..e80df95 --- /dev/null +++ b/example/example_server.dart @@ -0,0 +1,1536 @@ +/// Example OAuth1a server. +/// +/// Implements a server for OAuth 1a three-legged-OAuth. +/// +/// RFC 5849, "The OAuth 1.0 Protocol", April 2010. +/// +/// Note: the RFC supercedes the older "OAuth Core 1.0 Revision A", 24 June +/// 2009, which is sometimes referred to as OAuth1a. +/// +/// An OAuth server performs many tasks. In addition to implementing the +/// protocol, it also needs to track the _resource owners_ and registered +/// _clients_ and managing the lifecycle of _temporary credentials_ and _access +/// tokens_. +/// +/// This program is organised into these sections: +/// +/// - Constants +/// - Exception classes +/// - The entities: +/// - resource owners +/// - clients +/// - Code to generate random strings +/// - The OAuth1 tokens +/// - temporary credentials +/// - access tokens +/// - Framework for processing HTTP requests +/// - utility functions +/// - Http request handlers +/// - The main function + +import 'dart:async'; +import 'dart:convert'; + +import 'dart:io'; +import 'dart:math'; + +import 'package:args/args.dart'; +import 'package:encrypt/encrypt.dart'; +import 'package:oauth1/oauth1.dart' as oauth1; + +// Dart Linter overrides +// ignore_for_file: always_specify_types + +//################################################################ +// Constants +// +// These are all hard-coded to keep this example simple. + +//---------------------------------------------------------------- +// The host and port this server will listen on + +final InternetAddress address = InternetAddress.loopbackIPv6; +const int port = 8080; + +// Upper limit on the size of the HTTP request body. +const int _maxBodyLength = 1024 * 10; + +//---------------------------------------------------------------- +// Paths for the URIs implementing the OAuth1 protocol and protected resource + +const String tmpCredentialRequestUrl = '/oauth/request_token'; +const String resourceOwnerAuthUrl = '/oauth/authorize'; +const String tokenRequestUrl = '/oauth/access_token'; + +const String restrictedResourceUrl = '/1.1/statuses/home_timeline.json'; + +const String authIssuerPostUrl = '/issue-auth'; + +//---------------------------------------------------------------- +/// The resource owners +/// +/// Entities that owns/controls the protected resources. These resource owners +/// can approve a client's request to access the protected resources. + +final List resourceOwners = [ + ResourceOwnerInfo('armstrong', 'password'), + ResourceOwnerInfo('aldrin', '12345'), + ResourceOwnerInfo('collins', 'monkey'), +]; + +//---------------------------------------------------------------- +// The clients +// +// The programs that want to access the protected resources on this server. +// This test value corresponds to the default identity and secret hard-coded +// in the example client. Additional clients can be added via the command line. + +final List registeredClients = [ + ClientInfo('dart-oauth1-test', 'LLDeVY0ySvjoOVmJ2XgBItvTV', + sharedSecret: 'JmEpkWXXmY7BYoQor5AyR84BD2BiN47GIBUPXn3bopZqodJ0MV', + pemPublicKey: ''' +-----BEGIN RSA PUBLIC KEY----- +MIICCgKCAgEAsBYJRkO/c6eMgUosXpHXCBH5uE3+gR04IvkNzz5z9phaMxHUITSG +9qdJ7+sGgGnIl4Zd+NnwtfP+cUZaP46ySh+OHPFNt+MnwAd1hveJeG+9cB9Nd3je +ytdQHtqoE47kai7kNuLFEVHst0+wa3+aoJnrFckii5SK6g2tWiP9Z9IyiCLS7//U +GQQD3Q1zxqsTQCWKpQkcVKzkiq198pl2gI6qDsSO6cusg6tLqcf243C4/RkGf1EL +ug6AHte1T1ip0Czoj6VkmeiMUqSBvNmJOHLAuqaaltC+6Q07PC+Lm8/m1RJnQkmF +VOY1DDc/TSWwYO/DCsoarM3LjxFDTOSnhE4qZXn0f2hV48syqbavW0IKmCH+JHWW +oZgVm0ZDB3hMwlY2UaAnranw/EOONnAim2ebZoKbeaBX5KhtY1CNF6cMdNDx0D/B +4zZHcza3/BgN35PiVDj8teDX3bjwL2+sCkbaH9BKadal3VBw2RK7hPgMq26i57iY +AFDaXX9poFVZYrzHkVf2ja58TRF2fOZ85AV2uVoY0E3AN6GIPJQu16/SD6MPhneY +NiuqbV+RBsficySkdwRdcS8O+/FP928G67lEK2/akdhp0yhLlDQNlr2froIbBlaQ +xQVq0xyuiGr068ndvtFTiVVQh/JwC8bXMmh8IgI5A5XZb5AX0RpFcScCAwEAAQ== +-----END RSA PUBLIC KEY----- + ''') +]; + +//################################################################ +// Globals + +/// Program options that can be set via the command line. + +Arguments options; + +/// Tracks nonce/timestamp/token combinations that have been seen to prevent +/// replay attacks. +/// +/// TODO: implement a mechanism to delete old entries. +/// Currently, this just grows and grows until the server runs out of memory! + +Map seenNonceCombination = {}; + +//################################################################ +// Exceptions + +class HandlerNotFound implements Exception { + HandlerNotFound({this.methodKnown}); + final bool methodKnown; +} + +class BadAuthException implements Exception { + BadAuthException(this.message); + final String message; +} + +class BadRequestException implements Exception { + BadRequestException(this.message); + final String message; + + @override + String toString() => message; +} + +class ResourceOwnerUnknown implements Exception { + ResourceOwnerUnknown(this.username); + final String username; + + @override + String toString() => 'Unknown resource owner: $username'; +} + +class ClientUnknown implements Exception { + ClientUnknown(this.key); + final String key; + + @override + String toString() => 'Client unknown: $key'; +} + +class TemporaryCredentialUnknown implements Exception { + TemporaryCredentialUnknown(this.id); + final String id; + + @override + String toString() => 'Temporary credential unknown: $id'; +} + +class TemporaryCredentialExpired implements Exception { + TemporaryCredentialExpired(this.id); + final String id; + + @override + String toString() => 'Temporary credential expired: $id'; +} + +class AccessTokenUnknown implements Exception { + AccessTokenUnknown(this.id); + final String id; + + @override + String toString() => id != null + ? 'Access token unknown: $id' + : 'Access token was not provided'; +} + +class AccessTokenExpired implements Exception { + AccessTokenExpired(this.id); + final String id; + + @override + String toString() => 'Access token expired: $id'; +} + +class WrongLogin implements Exception {} + +//################################################################ +/// Resource owners. +/// +/// Represents the username and password used to authenticate the resource owner +/// when they login to the Web page to approve access. + +class ResourceOwnerInfo { + ResourceOwnerInfo(this.username, this.password); + + String username; + String password; // for example only: never store passwords in plaintext! + + /// Tests if an entered password matches the password. + + bool passwordMatches(String candidate) => candidate == password; + + /// Search for a resource owner by their [username]. + /// + /// Throws [ResourceOwnerUnknown] if not found. + + static ResourceOwnerInfo lookup(String username) => + resourceOwners.firstWhere((x) => x.username == username, + orElse: () => throw ResourceOwnerUnknown(username)); +} + +//################################################################ +/// Information about clients this server recognises. +/// +/// Clients are identified by its [identifier] and the [name] is used for +/// display purposes only. +/// +/// If using HMAC-SHA1 or PLAINTEXT, the client and this server both need to +/// have the same [sharedSecret]. If using RSA-SHA1, the [pemPublicKey] needs to +/// be the PEM formatted public key that corresponds to the private key the +/// client has. + +class ClientInfo { + ClientInfo(this.name, String identifier, + {String sharedSecret, String pemPublicKey}) { + final keyParser = RSAKeyParser(); + final pubKey = pemPublicKey != null ? keyParser.parse(pemPublicKey) : null; + + credentials = + oauth1.ClientCredentials(identifier, sharedSecret, publicKey: pubKey); + } + + String name; + + oauth1.ClientCredentials credentials; + + /// Search for a client by their [key]. + /// + /// Throws [ClientUnknown] if not found. + + static ClientInfo lookup(String key) => + registeredClients.firstWhere((x) => x.credentials.identifier == key, + orElse: () => throw ClientUnknown(key)); +} + +//################################################################ +// Code used to generate tokens, secrets and PINs. + +/// Alphabet used by the [randomString]. +/// +/// In this example, the random string function is also used for the PIN, which +/// is expected to be entered by a person. So ambiguous letters are excluded +/// (e.g. i, l, 0, O). + +const String _rndChars = + '23456789abcdefghjkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ'; + +/// Random number generator used by [randomString]. +/// +/// This should be cryptographically secure, since it is used to generated the +/// shared secrets. + +final _rnd = Random.secure(); +// Less secure implementation: +// final _rnd = Random(DateTime.now().millisecondsSinceEpoch); + +/// Generates a random string. +/// +/// A random string of [length] characters is returned. +/// +/// This function is used for generating the tokens and secrets. + +String randomString(int length) { + final buf = StringBuffer(); + for (var x = 0; x < length; x++) { + buf.write(_rndChars[_rnd.nextInt(_rndChars.length)]); + } + return buf.toString(); +} + +//################################################################ +// Temporary credential + +/// States of a temporary credential. +/// +/// A temporary credential is created in the _pendingVerification_ state. +/// Then, when the resource owner approves access, it is changed to the +/// _verified_ state. Then, when the client exchanges it for an access token, +/// it is changed to the _used_ state. If it reaches its lifetime before +/// it enters the _used_ state, it becomes _expired_. +/// +/// Previous versions of the specification referred to these as the "request +/// token" and its secret. This program only uses the word "token" in reference +/// to the "token credentials" (a.k.a. the access token). + +enum TmpCredState { pendingVerification, verified, used, expired } + +/// Temporary credentials. +/// +/// See section 2 of RFC 5849. + +class TemporaryCredentialInfo { + //================================================================ + /// Constructor + /// + /// Creates a new temporary credential for the [client] and records the + /// [callback] they wanted to use. + + TemporaryCredentialInfo(this.client, this.callback) + : identifier = randomString(16), + secret = randomString(48), + issued = DateTime.now() { + _state = TmpCredState.pendingVerification; + _allTmpCredentials[identifier] = this; + } + + //================================================================ + // Static members + + /// Life time of a temporary token + + static const Duration _maxLifeTime = Duration(minutes: 2); + + /// Tracks every temporary credential created since the server started. + /// + /// This example implementation does not delete expired credentials, to keep + /// the code simple and so it can tell the difference between an expired + /// credential and an identifier that has never been issued. + + static final Map _allTmpCredentials = {}; + + //================================================================ + // Members + + final String identifier; + final String secret; + + /// When the temporary token was issued + final DateTime issued; + + /// The client that requested the temporary credentials + final ClientInfo client; + + /// The callback the client wanted to receive the verifier on (or "oob") + final String callback; + + /// The resource owner who approved the temporary credential + /// Only has a value if it has been approved. + ResourceOwnerInfo approver; + + /// The verifier value assigned to this temporary credential + /// + /// Only has a value if it has been approved by a resource owner. The client + /// must present this value to exchange this temporary credential for an + /// access token. + String _verifier; + + TmpCredState _state; + + //================================================================ + // Methods + + //---------------------------------------------------------------- + /// Makes the temporary token "verified". + /// + /// The state is changed from _pendingVerification_ to _verified_ and a + /// value is assigned to the [verifier]. + /// + /// This is used when the resource owner approves the access. + + void verified(ResourceOwnerInfo approvedBy) { + assert(_state == TmpCredState.pendingVerification); + _state = TmpCredState.verified; + approver = approvedBy; + _verifier = randomString(4); + } + + //---------------------------------------------------------------- + /// Makes the temporary token "used". + /// + /// The state is changed from _verified_ to _used_. + /// + /// This is used when the client exchanges the temporary token for an access + /// token. + + void used() { + assert(_state == TmpCredState.verified); + _state = TmpCredState.used; + } + + //---------------------------------------------------------------- + /// The state of the temporary token. + /// + /// Initially, a temporary token is in the _pendingVerification_ state when it + /// is created and issued to a client. When the resource owner approves + /// access, the state becomes _verified_. Then when the client exchanges the + /// temporary token for an access token, the state becomes _used_. + /// + /// If the temporary token has not been used before its time-to-live, its + /// state becomes _expired_. + + TmpCredState get state => _state; + + //---------------------------------------------------------------- + /// The verifier value. + /// + /// This value which assigned by [verified] and which must match the value + /// presented by the client when it exchanges this temporary token for an + /// access token. + + String get verifier => _verifier; + + //---------------------------------------------------------------- + /// Lookups a temporary token by its identifier. + /// + /// Throws [TemporaryCredentialUnknown] if the is not known. + + static TemporaryCredentialInfo lookup(String identity) { + final tmpCred = _allTmpCredentials[identity]; + if (tmpCred == null) { + throw TemporaryCredentialUnknown(identity); + } + + if (tmpCred._state != TmpCredState.used && + tmpCred._state != TmpCredState.expired && + DateTime.now().isAfter(tmpCred.issued.add(_maxLifeTime))) { + // It has expired + tmpCred._state = TmpCredState.expired; + throw TemporaryCredentialExpired(identity); + } + + return tmpCred; + } +} + +//################################################################ +/// Manages the token credentials. +/// +/// Previous versions of the specification referred to these as the "access +/// token" and its secret. + +class TokenCredential { + TokenCredential(this.client, this.resourceOwner) + : identifier = randomString(32), + secret = randomString(48), + issued = DateTime.now() { + _allTokens[identifier] = this; + } + + //================================================================ + // Static members + + /// Life time of an access token. + /// + /// Depending on the server, some access tokens might never expire. + /// In this example, they expire after a few minutes. + + static const Duration _maxLifeTime = Duration(minutes: 5); + + /// Tracks all the access tokens that have been created. + /// + /// This example implementation does not delete expired tokens, to keep + /// the code simple and so it can tell the difference between an expired + /// token and an identifier that has never been issued. + + static final Map _allTokens = {}; + + //================================================================ + // Members + + final String identifier; + final String secret; + + /// When the access token was issued + final DateTime issued; + + // The client that the access token was issued to + final ClientInfo client; + + // The resource owner that approved the access request + final ResourceOwnerInfo resourceOwner; + + //================================================================ + // Methods + + //---------------------------------------------------------------- + + /// Throws [AccessTokenUnknown] if the Access Token is not known. + /// Throws [AccessTokenExpired] if the access token has expired. + + static TokenCredential lookup(String identity) { + final token = _allTokens[identity]; + if (token == null) { + throw AccessTokenUnknown(identity); + } + + if (DateTime.now().isAfter(token.issued.add(_maxLifeTime))) { + throw AccessTokenExpired(identity); + } + return token; + } +} + +//################################################################ +// Framework for processing HTTP requests. +// +// Functions to handle HTTP requests match the [Handler] function type. +// +// Every URI supported by the server is represented in either [getHandlers] +// or [postHandlers], for handling HTTP GET requests and HTTP POST requests +// respectively. Those are maps from the path of the URI to the handler +// function. +// +// The [processHttpRequests] function listens for HTTP requests and dispatches +// them to the appropriate handler function. + +//---------------------------------------------------------------- +// Function type for HTTP request handling functions. + +typedef Handler = Future Function(HttpRequest request); + +//---------------------------------------------------------------- +// Maps of all supported HTTP requests. + +final Map getHandlers = { + '/': handleHomePage, + resourceOwnerAuthUrl: handleResourceOwnerAuthRequest, + restrictedResourceUrl: handleExampleResource, +}; + +final Map postHandlers = { + tmpCredentialRequestUrl: handleTmpCredentialsRequest, + tokenRequestUrl: handleTokenRequest, + authIssuerPostUrl: handleResourceOwnerAuthRequestPost, +}; + +//---------------------------------------------------------------- +/// Listen for HTTP requests and process them. + +Future processHttpRequests(HttpServer server) async { + await for (final HttpRequest request in server) { + final path = request.requestedUri.path; + + if (Verbosity.quiet.index < options.verbosity.index) { + print('${DateTime.now()}: ${request.method} $path'); + } + + try { + // Try to find a handler to process the request + + Handler handler; + + switch (request.method) { + case 'GET': + handler = getHandlers[path]; + break; + case 'POST': + handler = postHandlers[path]; + break; + default: + throw HandlerNotFound(methodKnown: false); + } + + if (handler == null) { + throw HandlerNotFound(methodKnown: true); + } + + // Invoke the handler + + await handler(request); + } catch (e) { + // Something went wrong: generate an error HTTP response + + generateErrorResponse(e, request.response); + } + } +} + +//---------------------------------------------------------------- +/// Generates a HTTP response based on the exception that was thrown. +/// +/// Warning: this might reveal more information to the user/client than a +/// production server should. This is just an example. + +void generateErrorResponse(Object exception, HttpResponse resp) { + if (exception is HandlerNotFound) { + if (exception.methodKnown) { + _errorHtml(HttpStatus.notFound, 'Not found', resp); + } else { + _errorHtml(HttpStatus.methodNotAllowed, 'Method not allowed', resp); + } + } else if (exception is BadAuthException) { + _errorAuth(exception.message, resp); + } else if (exception is oauth1.BadOAuth) { + _errorAuth('Invalid request: $exception', resp); + } else if (exception is BadRequestException) { + _errorHtml(HttpStatus.badRequest, 'Bad request: $exception', resp); + } else if (exception is WrongLogin) { + _errorHtml(HttpStatus.badRequest, 'Invalid login', resp); + } else { + _errorHtml(HttpStatus.internalServerError, exception.toString(), resp); + } +} + +// Generates a HTML error page HTTP response. +// Only for use by [generateErrorResponse]. + +void _errorHtml(int status, String message, HttpResponse resp) { + print('${DateTime.now()}: $message'); + + resp.statusCode = status; + resp.headers.contentType = ContentType.html; + + resp.write(''' + + +OAuth1 Example Server: Error + + + + +

Error

+ +

$message

+ +

Home

+ + +'''); + + resp.close(); +} + +// Generates an OAuth unauthorized HTTP response. +// Only for use by [generateErrorResponse]. + +void _errorAuth(String message, HttpResponse resp) { + print('${DateTime.now()}: bad auth: $message'); + + resp.statusCode = HttpStatus.unauthorized; + resp.headers.contentType = ContentType('application', 'json'); + + final realm = 'http://${hostname(address)}:$port'; + + resp.headers.add('www-authenticate', 'OAuth realm="$realm'); + resp.write('{"errors":[{"message": "could not authenticate"}]\n'); + + resp.close(); +} + +//################################################################ +// Utility classes and functions + +//---------------------------------------------------------------- +/// Convert the address the server is listening on to a value that can be +/// used as the hostname in a URI. + +String hostname(InternetAddress address) => + (address.isLoopback) ? 'localhost' : address.host; + +//---------------------------------------------------------------- +/// Query parameters +/// +/// Represents parameters from the query parameters of a URI or from a +/// url-encoded body of a HTTP request. It is a set of name-value pairs, where +/// there could be multiple pairs with the same name and/or value. Both name +/// and value are case sensitive. + +class QueryParams { + /// Constructor that parses a query string. + + QueryParams.fromQueryString(String queryStr, {Encoding encoding = utf8}) { + for (final String pair in queryStr.split('&')) { + if (pair.isNotEmpty) { + final index = pair.indexOf('='); + if (index == -1) { + // no "=": use whole string as key and the value is empty string + final key = Uri.decodeQueryComponent(pair, encoding: encoding); + _add(key, ''); // no "=" found, treat value as empty string + } else if (index != 0) { + final key = pair.substring(0, index); + final value = pair.substring(index + 1); + _add(Uri.decodeQueryComponent(key, encoding: encoding), + Uri.decodeQueryComponent(value, encoding: encoding)); + } else { + // Has "=", but is first character: key is empty string + _add('', + Uri.decodeQueryComponent(pair.substring(1), encoding: encoding)); + } + } + } + } + + /// Limit on the size of POST request bodies + + static const int maxBodySize = 10 * 1024; // bytes + + final Map> values = {}; + + @override + String toString() { + final StringBuffer buf = StringBuffer(); + for (final key in values.keys) { + buf.write('$key=[${values[key].map((s) => '"$s"').join(', ')}]'); + } + return buf.toString(); + } + + void _add(String key, String value) { + if (!values.containsKey(key)) { + values[key] = []; // create array of values + } + values[key].add(value); // append new value to the array of values + } + + /// Retrieve a single value. + /// + /// If there are multiple values with the same name, only the first is + /// returned and the others are ignored. + + String operator [](String name) { + if (values.containsKey(name)) { + return values[name].first; + } else { + return null; + } + } + + /// Creates a [QueryParams] by parsing the body of a HTTP response where + /// the MIME type is 'application/x-www-form-urlencoded'. This is usually + /// produced by a POST request from a HTML form, but can also be used for + /// other HTTP POST and PUT requests. + + static Future fromBody(HttpRequest request) async { + if (request.headers.contentType != null) { + final String mimeType = request.headers.contentType.mimeType; + if (mimeType != 'application/x-www-form-urlencoded') { + throw BadRequestException('unexpected Content-Type: $mimeType'); + } + + final bodyBytes = []; + await for (final Iterable bytes in request) { + if (maxBodySize < bodyBytes.length + bytes.length) { + throw BadRequestException('body too large'); + } + bodyBytes.addAll(bytes); + } + + final bodyStr = utf8.decode(bodyBytes, allowMalformed: false); + + return QueryParams.fromQueryString(bodyStr); + } else { + return null; + } + } +} + +//---------------------------------------------------------------- +/// Extracts parameters from an HTTP request. + +Future _getOAuthRequest( + HttpRequest request) async { + // Get www-form-urlencoded body, if any + + String urlEncodedBody; + + final ContentType contentType = request.headers.contentType; + if (contentType != null && + contentType.mimeType == 'application/x-www-form-urlencoded') { + // Request has a Content-Type header and it is the expected mime-type + + final bodyBytes = []; + await for (final Iterable bytes in request) { + if (_maxBodyLength < bodyBytes.length + bytes.length) { + throw BadRequestException('body too large'); + } + bodyBytes.addAll(bytes); + } + + if (contentType.charset != 'utf8') { + throw FormatException('unsupported charset: ${contentType.charset}'); + } + + urlEncodedBody = utf8.decode(bodyBytes, allowMalformed: false); + } + + // Extract all the parameters that are signed by OAuth1 from: + // - query parameters in the URI + // - OAuth authorization headers + // - www-form-urlencoded body + + final params = oauth1.AuthorizationRequest.fromHttpRequest(request.method, + request.requestedUri, request.headers['authorization'], urlEncodedBody); + + if (Verbosity.normal.index < options.verbosity.index) { + print(params); + } + + return params; +} + +//---------------------------------------------------------------- +/// Records and checks the nonce, timestamp and token. +/// +/// Section 3.2 of RFC 5849 says: "If using the "HMAC-SHA1" or "RSA-SHA1" +/// signature methods, ensuring that the combination of nonce/timestamp/token +/// (if present) received from the client has not been used before in a previous +// request (the server MAY reject requests with stale timestamps as +// described in Section 3.3). + +void checkNonce(oauth1.AuthorizationRequest auth) { + if (auth.signatureMethod == oauth1.SignatureMethods.hmacSha1.name || + auth.signatureMethod == oauth1.SignatureMethods.rsaSha1.name) { + final nonce = auth.nonce; + assert(nonce != null, 'nonce missing: parser did not detect this'); + if (nonce == null || nonce.length < 4) { + throw BadRequestException('Nonce is too short for my liking'); + } + + final timestamp = auth.timestamp; + + final token = auth.token; + + final combination = '$nonce:$timestamp:$token'; + if (seenNonceCombination.containsKey(combination)) { + throw BadRequestException('OAuth HTTP request has already been received'); + } + + seenNonceCombination[combination] = true; + } +} + +//################################################################ +// The HTTP request handlers + +//---------------------------------------------------------------- +/// Handle a request for a Temporary Credential. +/// +/// This is the first leg in the three-legged-OAuth. The client sends a request +/// to this server to obtain a new temporary credential. + +Future handleTmpCredentialsRequest(HttpRequest request) async { + assert(request.method == 'POST'); + + // From the OAuth header, identify the client and validate the header was + // signed by that client. + + final oauthRequest = await _getOAuthRequest(request); + + final client = ClientInfo.lookup(oauthRequest.clientIdentifier); + if (Verbosity.quiet.index < options.verbosity.index) { + print( + ' request from client="${client.name}" using ${oauthRequest.signatureMethod}'); + } + + oauthRequest.validate(client.credentials); // no token secret for this request + + checkNonce(oauthRequest); + + // Get the callback the client has indicated it wants to use + + final callback = oauthRequest.callback; + + if (callback == null) { + throw BadRequestException('oauth_callback missing'); + } + if (callback != 'oob' && !callback.startsWith('http')) { + throw BadRequestException( + 'oauth_callback is not "oob" or HTTP URL: $callback'); + } + + // Success: issue a temporary token to the client + + final tmpCred = TemporaryCredentialInfo(client, callback); + + if (Verbosity.quiet.index < options.verbosity.index) { + print(' issued temporary credential: ${tmpCred.identifier}'); + } + + // Produce response containing the temporary credential + + final resp = request.response; + + resp.headers.contentType = + ContentType('application', 'x-www-form-urlencoded'); + + final Map p = { + 'oauth_token': tmpCred.identifier, + 'oauth_token_secret': tmpCred.secret, + 'oauth_callback_confirmed': 'true', + }; + + resp.write(p.keys.map((k) => '$k=${Uri.encodeComponent(p[k])}').join('&')); + resp.close(); +} + +//---------------------------------------------------------------- +/// Resource Owner Authorization. +/// +/// This is the second leg in the three-legged-OAuth. After obtaining a +/// temporary credential, the client will ask the resource owner to visit this +/// page (e.g. by redirecting their browser) to authorize the request for +/// access. +/// +/// Implements +/// [section 2.2 of RFC 5849](https://tools.ietf.org/html/rfc5849#section-2.2). + +Future handleResourceOwnerAuthRequest(HttpRequest request) async { + assert(request.method == 'GET'); + + // The temporary token is provided as a query parameter + + final tmpTokenId = request.uri.queryParameters['oauth_token']; + final tmpCred = TemporaryCredentialInfo.lookup(tmpTokenId); + + if (tmpCred.state != TmpCredState.pendingVerification) { + throw BadRequestException('temporary credential: ${tmpCred.state}'); + } + + // Produce response: a form asking the resource owner to login and approve + + final resp = request.response; + resp.headers.contentType = ContentType.html; + resp.statusCode = HttpStatus.ok; + resp.write(''' + + +OAuth1 Example Server: Authorizing client + + + + +

Authorizing client

+ +

The ${tmpCred.client.name} client has requested +permission to access your resources.

+ +

If you want to give it access, enter your username and password and press the +authorize button.

+ +
+ + + + + + + + + + + + + + + +
+ +
+ + + '''); + + resp.close(); +} + +//---------------- +// Process the form + +Future handleResourceOwnerAuthRequestPost(HttpRequest request) async { + try { + assert(request.method == 'POST'); + + final postParams = await QueryParams.fromBody(request); + assert(postParams != null); + + // Check the username and password + + final owner = ResourceOwnerInfo.lookup(postParams['username']); + + if (!owner.passwordMatches(postParams['password'])) { + throw WrongLogin(); + } + + // Mark the temporary credential as having been verified + + final tmpCred = TemporaryCredentialInfo.lookup(postParams['oauth_token']); + + if (tmpCred.state != TmpCredState.pendingVerification) { + throw BadRequestException('temporary credential: ${tmpCred.state}'); + } + + // Success + + tmpCred.verified(owner); + + if (Verbosity.quiet.index < options.verbosity.index) { + print(' approved by resource owner ${owner.username}' + ' for temporary credential=${tmpCred.identifier}'); + print(' verifier for client to present: ${tmpCred.verifier}'); + } + + // Produce the response that returns the verifier to the client + // (either directly via the callback the client provided when it asked for + // the temporary credential) or display it for it to be done out-of-band. + + final resp = request.response; + + if (tmpCred.callback != 'oob') { + // Redirect to the client's callback with information in query parameters + + final uri = Uri.parse(tmpCred.callback); + uri.queryParameters['oauth_token'] = tmpCred.identifier; + uri.queryParameters['oauth_verifier'] = tmpCred.verifier; + + resp.statusCode = HttpStatus.temporaryRedirect; + resp.headers.set(HttpHeaders.locationHeader, uri.toString()); + } else { + // Cannot use redirect. Display the value of the verification code. + + resp.headers.contentType = ContentType.html; + resp.write(''' + + +OAuth1 Example Server: Authorization successful + + + + +

Authorization successful

+ +

Please provide the ${tmpCred.client.name} +client this PIN: ${tmpCred.verifier}

+ + + '''); + } + + resp.close(); + } on ResourceOwnerUnknown { + // The username was wrong, but don't reveal that fact to the user. + // Treat it the same as if the password was wrong. + throw WrongLogin(); + } +} + +//---------------------------------------------------------------- +/// Issues token credentials. +/// +/// This is the third leg in the three-legged-OAuth. The client has obtained +/// the verifier (either via a callback or via an out-of-band mechanism) and +/// now wants to exchange the temporary token for an access token. +/// +/// Implements +/// [section 2.3 of RFC 5849])https://tools.ietf.org/html/rfc5849#section-2.3). + +Future handleTokenRequest(HttpRequest request) async { + assert(request.method == 'POST'); + + // From the OAuth header, identify the client and the temporary credential, + // validate the signature was produced by the client (for that temporary + // credential) and that the verifier is correct for that temporary + // credential. + + final oauthRequest = await _getOAuthRequest(request); + if (Verbosity.quiet.index < options.verbosity.index) { + print(' temporary credential=${oauthRequest.token}' + ' verifier=${oauthRequest.verifier}' + ' using ${oauthRequest.signatureMethod}'); + } + + final client = ClientInfo.lookup(oauthRequest.clientIdentifier); + + final tmpCred = TemporaryCredentialInfo.lookup(oauthRequest.token); + + oauthRequest.validate(client.credentials, tmpCred.secret); + + checkNonce(oauthRequest); + + if (tmpCred.verifier != oauthRequest.verifier) { + // Incorrect verifier + // + // Normally this is ALWAYS an error. But for testing, the backdoor value + // may be accepted as a substitute for the correct value. + + if (options.allowBackdoor && oauthRequest.verifier == 'backdoor') { + // This is only for testing. Do not use in any production system! + // It automatically authorizes the temporary credential (by the first + // resource owner in the list) and treats the verifier as being correct. + // To make testing quicker, this saves the tester from manually logging + // into the server to authorize the request. + print(' WARNING: treating verifier as correct even though it is not'); + tmpCred.verified(resourceOwners.first); + } else { + throw BadRequestException('wrong verifier'); + } + } + + if (tmpCred.client.credentials.identifier != client.credentials.identifier) { + throw BadRequestException('temporary credential does not belong to client'); + } + if (tmpCred.state != TmpCredState.verified) { + throw BadRequestException( + 'temporary credential has not been authorised by a resource owner'); + } + + // Success: issue the client an access token + + tmpCred.used(); // mark the temporary credential as used + + final TokenCredential accessToken = TokenCredential(client, tmpCred.approver); + + if (Verbosity.quiet.index < options.verbosity.index) { + print(' issued access token: ${accessToken.identifier}'); + } + + // Produce response with the access token + + final resp = request.response; + + resp.headers.contentType = + ContentType('application', 'x-www-form-urlencoded'); + + final Map p = { + 'oauth_token': accessToken.identifier, + 'oauth_token_secret': accessToken.secret, + 'screen_name': tmpCred.approver.username, // example of optional parameter + }; + + resp.write(p.keys.map((k) => '$k=${Uri.encodeComponent(p[k])}').join('&')); + resp.close(); +} + +//---------------------------------------------------------------- +/// Handler for the protected resource. +/// +/// This is the access-restricted resource the client is ultimately trying to +/// access, but needs to go through the OAuth process to obtain an access token +/// to be able to access it. + +Future handleExampleResource(HttpRequest request) async { + // From the OAuth header, get the client and access token and validate the + // signature. + + final oauthRequest = await _getOAuthRequest(request); + + final client = ClientInfo.lookup(oauthRequest.clientIdentifier); + + String info; + + if ((!options.allowTwoLegged) || oauthRequest.token != null) { + // Must use three-legged-OAuth or the client is using it anyway. + // The client MUST present a valid access token and the request is valid. + + final accessToken = TokenCredential.lookup(oauthRequest.token); + if (Verbosity.quiet.index < options.verbosity.index) { + print(' access token=${oauthRequest.token}' + ' using ${oauthRequest.signatureMethod}'); + } + + if (accessToken.client.credentials.identifier != + client.credentials.identifier) { + // Section 3.2 of RFC5 849 says "the server MAY choose to restrict token + // usage to the client to which it was issued". + // Some implementations of a server might not treat this as an error. + throw BadRequestException('access token does not belong to client'); + } + + oauthRequest.validate(client.credentials, accessToken.secret); + + info = ' resourceOwner="${accessToken.resourceOwner.username}"'; + } else { + // Two-legged-OAuth: there is no token. + // Just validating the client had signed the request is sufficient. + + if (Verbosity.quiet.index < options.verbosity.index) { + print(' two-legged-OAuth request from ${client.name}' + ' using ${oauthRequest.signatureMethod}'); + } + + oauthRequest.validate(client.credentials); + + info = ''; + } + + checkNonce(oauthRequest); + + // Success: allow access to the resource. + + if (Verbosity.quiet.index < options.verbosity.index) { + print(' access allowed'); + } + + // Produce response + + final response = request.response; + + response.headers.contentType = ContentType('application', 'json'); + response.write('{"title":"Protected resource",$info' + ' "being-accessed-by":"${client.name}"}'); + response.close(); +} + +//---------------------------------------------------------------- +/// Handler for the home page. +/// +/// Show some information about this example server. + +Future handleHomePage(HttpRequest request) async { + final resp = request.response; + + resp.headers.contentType = ContentType.html; + resp.write(''' + + +OAuth1 Example Server + + + + +

OAuth1 Example Server

+ +

OAuth1 endpoints

+ +

OAuth1 clients need to be configured to use these URI endpoints, and have +client credentials that are recognised by this server.

+ + + + + + + + + + + + + + +
Temporary Credential Requesthttp://${hostname(address)}:$port$tmpCredentialRequestUrl
Resource Owner Authorizationhttp://${hostname(address)}:$port$resourceOwnerAuthUrl
Token Requesthttp://${hostname(address)}:$port$tokenRequestUrl
+ + + + '''); + + resp.close(); +} + +//################################################################ +// Command line parsing + +/// Verbosity level for information printed. +/// +/// Default = 1; quiet = 0; verbose = 2; + +enum Verbosity { quiet, normal, verbose, debug } + +//---------------- + +class Arguments { + /// Constructor from command line arguments + + Arguments(List args) { + try { + programName = Platform.script.pathSegments.last.replaceAll('.dart', ''); + + final parser = ArgParser(allowTrailingOptions: true) + ..addFlag('two-legged-oauth', + abbr: '2', help: 'allow two-legged-OAuth', negatable: false) + ..addFlag('backdoor', + abbr: 'B', help: 'enable backdoor verifier', negatable: false) + ..addFlag('debug', + abbr: 'D', help: 'show debug information', negatable: false) + ..addFlag('quiet', + abbr: 'q', help: 'show less information', negatable: false) + ..addFlag('verbose', + abbr: 'v', help: 'show more information', negatable: false) + ..addFlag('help', + abbr: 'h', help: 'show this help message', negatable: false); + + final results = parser.parse(args); + + programName = results.name ?? programName; + + // Help flag + + if (results['help']) { + print( + 'Usage: $programName [options] {client_credential_files}\nOptions:'); + print(parser.usage); + exit(0); + } + + allowTwoLegged = results['two-legged-oauth']; + + allowBackdoor = results['backdoor']; + + // Level of output + + if (results['debug']) { + verbosity = Verbosity.debug; + } else if (results['verbose']) { + verbosity = Verbosity.verbose; + } else if (results['quiet']) { + verbosity = Verbosity.quiet; + } else { + verbosity = Verbosity.normal; + } + + // Remaining arguments are filenames for client credentials + + for (final filename in results.rest) { + if (Verbosity.verbose.index < verbosity.index) { + print('Loading client credentials from "$filename"'); + } + _loadCredentialsFromFile(filename); + } + } on FormatException catch (e) { + stderr.write('Usage error: $programName: ${e.message}\n'); + exit(2); + } + } + + //================================================================ + // Members + + String programName; + + bool allowTwoLegged; + + bool allowBackdoor = false; + + Verbosity verbosity = Verbosity.normal; + + //================================================================ + // Methods + + //---------------------------------------------------------------- + /// Loads client credentials from a file. + /// + /// The file may contain zero or more of the following: + /// + /// - client identity (property name "oauth_consumer_key") + /// - shared secret (property name "secret") + /// - RSA public key (must be in PEM format) + /// + /// It must not contain a private key, since an OAuth1 server should not have + /// access to the client's private key. + + void _loadCredentialsFromFile(String filename) { + try { + String name = filename; // defaults to filename if no "name" in file + String identity; // client's identity + String sharedSecret; + String publicKeyText; + + // Parse the file + + int mode = 0; + StringBuffer buf; + + var lineNum = 0; + for (final line in File(filename).readAsLinesSync()) { + lineNum++; + if (mode == 0) { + // Normal mode + if (line.trim().isEmpty || line.trim().startsWith('#')) { + // comment or blank line: ignore + } else if (line.startsWith('-----BEGIN RSA PUBLIC KEY-----')) { + mode = 1; // start capturing lines + buf = StringBuffer(line)..write('\n'); + } else { + final pair = line.split(':'); + if (pair.length == 2) { + final key = pair[0].trim(); + final value = pair[1].trim(); + switch (key) { + case 'name': + name = value; + break; + case 'oauth_consumer_key': + identity = value; + break; + case 'secret': + sharedSecret = value; + break; + + default: + stderr.write('$filename: $lineNum: unknown name: $name\n'); + exit(1); + } + } else if (line.contains('BEGIN RSA PRIVATE KEY')) { + stderr.write( + '$filename: $lineNum: private key (servers shouldn\'t have client\'s private key)\n'); + exit(1); + } else { + stderr.write('$filename: $lineNum: unexpected line: $line\n'); + exit(1); + } + } + } else if (mode == 1) { + // Reading private key + buf..write(line)..write('\n'); + if (line.startsWith('-----END RSA PUBLIC KEY-----')) { + mode = 0; // finished public key + publicKeyText = buf.toString(); + buf = null; + } + } + } + + if (mode != 0) { + stderr.write('$filename: error: incomplete: missing end of key\n'); + exit(1); + } + + // Check file contained the necessary information + + if (identity == null) { + stderr.write('$filename: error: no "oauth_consumer_key" in file\n'); + exit(1); + } + if (sharedSecret == null && publicKeyText == null) { + stderr.write('$filename: error: no "secret" or public key in file\n'); + exit(1); + } + + // Register the client in the server + + registeredClients.add(ClientInfo(name, identity, + sharedSecret: sharedSecret, pemPublicKey: publicKeyText)); + } catch (e) { + stderr.write('$filename: $e\n'); + exit(1); + } + } +} + +//================================================================ + +Future main(List arguments) async { + // Command line processing + + options = Arguments(arguments); + + // Output some information about this example server + + final legs = options.allowTwoLegged + ? 'two-legged-OAuth and three-legged-OAuth' + : 'three-legged-OAuth only'; + + if (Verbosity.quiet.index < options.verbosity.index) { + print('''OAuth1 Server (supports $legs) + +Clients that can request access this server:'''); + + for (final c in registeredClients) { + print(' ${c.name}:'); + print(' Client identity: ${c.credentials.identifier}'); + if (c.credentials.sharedSecret != null) { + print(' Shared secret: ${c.credentials.sharedSecret}'); + } + if (c.credentials.publicKey != null) { + final usage = (c.credentials.sharedSecret != null) + ? 'is supported for' + : 'must be used by'; + print(' Public key: yes (RSA-SHA1 $usage this client)'); + } + } + + print('\nResource owners who can authorize requests for access:'); + + for (final owner in resourceOwners) { + print(' Username: ${owner.username}; password: ${owner.password}'); + } + print(''' + +Please run the example client like this: + + dart example_client.dart -s http://${hostname(address)}:$port +'''); + } + + // Run the HTTP server + + final HttpServer server = await HttpServer.bind(address, port); + + await processHttpRequests(server); +} diff --git a/example/tester1.secret b/example/tester1.secret new file mode 100644 index 0000000..58f4886 --- /dev/null +++ b/example/tester1.secret @@ -0,0 +1,7 @@ +# Consumer credentials for HMAC-SHA1 or PLAINTEXT +# For use with both the OAuth1 client and server. + +name: Tester with shared secret +oauth_consumer_key: 3dFbpyt4MyWywAvS8jgAPdHVhKJ2KsBw + +secret: J3dFbpyt4MyWywAvS8jgAPdHVhKJ2K diff --git a/example/tester2.private b/example/tester2.private new file mode 100644 index 0000000..f7389d6 --- /dev/null +++ b/example/tester2.private @@ -0,0 +1,57 @@ +# Consumer credentials for RSA-SHA1 +# For use with the OAuth1 client only. + +name: Tester with RSA key pair +oauth_consumer_key: yyFT8nBLP4ZXPA2BsTMQxwkw6BfL2LJj + +-----BEGIN RSA PRIVATE KEY----- +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 +-----END RSA PRIVATE KEY----- diff --git a/example/tester2.public b/example/tester2.public new file mode 100644 index 0000000..7596d1d --- /dev/null +++ b/example/tester2.public @@ -0,0 +1,20 @@ +# Consumer credentials for RSA-SHA1 +# For use with the OAuth1 server. + +name: Tester with RSA key pair +oauth_consumer_key: yyFT8nBLP4ZXPA2BsTMQxwkw6BfL2LJj + +-----BEGIN RSA PUBLIC KEY----- +MIICCgKCAgEAv7/Mrdu54nkCPu50gYFH6LQz0DKoaqOdmfllLrpJbwHCZhJ59UcH +ij9ZjgNIEX//wiFKQ9OE4fiE3EMHaC2RrATKWpTGQ0ot6UBHfb4+XPooWGxU0LpW +rLCuycwcR/Z0Wrj7Poqdg8cNo2vOWMXpZ+rphUaIziN2POko+mvlHvG6JD0q2UAy +u5NHXpHjuxT2R6RVxQE/U9Z0nd1tCnWjVk4/AlNbCC857ePL09QKLYNhYHoVbgeL +bWrYX3CVJ+hzqKX5m1viIuj990m73F90tSRF1l5ZEi1LE8bwcZ6jO4FkNLoNXiaL +JkaUk69hWCX+lZKpTNjHO8sLYHaWWsmv8RR/8pZov39IhrT/7G8IEmjBAlgHtO9s +8MXe37ectsLNZj2y5gqWdz1xPxMYUf4zlSCoBQo33EQrx3ND4XBk0bVFzKtX53vv +w6nOGg8bA16tGM5sQLdF+EsGBHHrA3oN+tJ75abYeqtZLJlzV5XcG/ikhXZIq2lf +AbtMuelB/SnxWinn8784QKHxASkB0NrwSrT+G2Gbvhgmlgv6Eeln7yBxz9Cg2dMx +bkirXPfKGIu+u9FuS3of+LynwDnKMCZnoxU3ZtunywKC4yVu4mgm8oFGqR4y9OIh +L8C9qGPj6wUSkrYclw3b8RecRzWRQZYKXFIT6bAc7VWpPRPPXf60r98CAwEAAQ== +-----END RSA PUBLIC KEY----- + diff --git a/lib/oauth1.dart b/lib/oauth1.dart index 5919b65..55c4847 100644 --- a/lib/oauth1.dart +++ b/lib/oauth1.dart @@ -1,9 +1,13 @@ +/// Implementation of the OAuth 1.0 protocol as defined by RFC 5849. + library oauth1; export 'src/client_credentials.dart'; export 'src/credentials.dart'; +export 'src/exceptions.dart'; export 'src/signature_method.dart'; export 'src/client.dart'; export 'src/authorization.dart'; +export 'src/authorization_request.dart'; export 'src/authorization_response.dart'; export 'src/platform.dart'; diff --git a/lib/src/authorization.dart b/lib/src/authorization.dart index 9a52666..d20f7df 100644 --- a/lib/src/authorization.dart +++ b/lib/src/authorization.dart @@ -2,18 +2,29 @@ library authorization; import 'dart:async'; import 'package:http/http.dart' as http; +import 'package:oauth1/oauth1.dart'; import 'credentials.dart'; import 'client_credentials.dart'; import 'platform.dart'; -import 'authorization_header_builder.dart'; import 'authorization_response.dart'; /// A proxy class describing OAuth 1.0 redirection-based authorization. -/// http://tools.ietf.org/html/rfc5849#section-2 /// -/// Redirection works are responded to client. -/// So you can do PIN-based authorization too if you want. +/// Provides high-level methods for implementing the client-side of +/// the three-legged-OAuth protocol as documented in +/// [section 2 of RFC 5849](http://tools.ietf.org/html/rfc5849#section-2). +/// +/// 1. Client requests a _temporary credential_ from the server using +/// [requestTemporaryCredentials]. +/// 2. The client asks the _resource owner_ to visit the resource owner +/// authorization URI, obtained using [getResourceOwnerAuthorizationURI]. +/// 3. If the resource owner authorizes the request, the client receives a +/// verifier code; either via a callback or by an out-of-bands mechanism. +/// 4. The client presents the verifier and temporary token to the server, +/// to obtain a _token credential_ [requestTokenCredentials]. +/// 5. The client uses the token credential to access protected resources. + class Authorization { final ClientCredentials _clientCredentials; final Platform _platform; @@ -30,23 +41,30 @@ class Authorization { /// Obtain a set of temporary credentials from the server. /// http://tools.ietf.org/html/rfc5849#section-2.1 /// - /// If not callbackURI passed, authentication becomes PIN-based. + /// The [callbackURI] is optional. If provided, it must be a URI for the + /// callback's endpoint, or the value must be "oob" (case significant) + /// indicating the verifier will be communicated to the client via an + /// out-of-band mechanism. If the _callBackURI_ is omitted, it defaults to + /// "oob", which usually means the server shows the resource owner a PIN + /// to manually enter into the client. + Future requestTemporaryCredentials( [String callbackURI]) async { - callbackURI ??= 'oob'; - final Map additionalParams = { - 'oauth_callback': callbackURI - }; - final AuthorizationHeaderBuilder ahb = AuthorizationHeaderBuilder(); - ahb.signatureMethod = _platform.signatureMethod; - ahb.clientCredentials = _clientCredentials; - ahb.method = 'POST'; - ahb.url = _platform.temporaryCredentialsRequestURI; - ahb.additionalParameters = additionalParams; + // TODO: allow optional parameters to be included in the request + // Since section 2.1 of RFC 5849 says "servers MAY specify additional + // parameters". This applies to the other high-level methods too. + + final AuthorizationRequest auth = AuthorizationRequest(); + auth.set(AuthorizationRequest.oauth_version, + AuthorizationRequest.supportedVersion); // optional + auth.set(AuthorizationRequest.oauth_callback, callbackURI ?? 'oob'); + + auth.sign('POST', Uri.parse(_platform.temporaryCredentialsRequestURI), + _clientCredentials, _platform.signatureMethod); final http.Response res = await _httpClient.post( _platform.temporaryCredentialsRequestURI, - headers: {'Authorization': ahb.build().toString()}); + headers: {'Authorization': auth.headerValue()}); if (res.statusCode != 200) { throw StateError(res.body); @@ -54,7 +72,9 @@ class Authorization { final Map params = Uri.splitQueryString(res.body); if (params['oauth_callback_confirmed'].toLowerCase() != 'true') { - throw StateError('oauth_callback_confirmed must be true'); + // Note: this is more forgiving that the specification intended. + // The specification does not say "TRUE" is permitted as a response. + throw StateError('oauth_callback_confirmed must be "true"'); } return AuthorizationResponse.fromMap(params); @@ -73,20 +93,18 @@ class Authorization { /// http://tools.ietf.org/html/rfc5849#section-2.3 Future requestTokenCredentials( Credentials tokenCredentials, String verifier) async { - final Map additionalParams = { - 'oauth_verifier': verifier - }; - final AuthorizationHeaderBuilder ahb = AuthorizationHeaderBuilder(); - ahb.signatureMethod = _platform.signatureMethod; - ahb.clientCredentials = _clientCredentials; - ahb.credentials = tokenCredentials; - ahb.method = 'POST'; - ahb.url = _platform.tokenCredentialsRequestURI; - ahb.additionalParameters = additionalParams; + final AuthorizationRequest auth = AuthorizationRequest(); + auth.set(AuthorizationRequest.oauth_version, + AuthorizationRequest.supportedVersion); + auth.set(AuthorizationRequest.oauth_verifier, verifier); + + auth.sign('POST', Uri.parse(_platform.tokenCredentialsRequestURI), + _clientCredentials, _platform.signatureMethod, + tokenCredentials: tokenCredentials); final http.Response res = await _httpClient.post( _platform.tokenCredentialsRequestURI, - headers: {'Authorization': ahb.build().toString()}); + headers: {'Authorization': auth.headerValue()}); if (res.statusCode != 200) { throw StateError(res.body); diff --git a/lib/src/authorization_header.dart b/lib/src/authorization_header.dart deleted file mode 100644 index 3a8dcfe..0000000 --- a/lib/src/authorization_header.dart +++ /dev/null @@ -1,144 +0,0 @@ -library authorization_header; - -// import 'package:uuid/uuid.dart'; - -import 'signature_method.dart'; -import 'client_credentials.dart'; -import 'credentials.dart'; - -/// A class describing Authorization Header. -/// http://tools.ietf.org/html/rfc5849#section-3.5.1 -class AuthorizationHeader { - final SignatureMethod _signatureMethod; - final ClientCredentials _clientCredentials; - final Credentials _credentials; - final String _method; - final String _url; - final Map _additionalParameters; - - // static final _uuid = new Uuid(); - - AuthorizationHeader(this._signatureMethod, this._clientCredentials, - this._credentials, this._method, this._url, this._additionalParameters); - - /// Set Authorization header to request. - /// - /// Below parameters are provided default values: - /// - oauth_signature_method - /// - oauth_signature - /// - oauth_timestamp - /// - oauth_nonce - /// - oauth_version - /// - oauth_consumer_key - /// - oauth_token - /// - oauth_token_secret - /// - /// You can add parameters by _authorizationHeader. - /// (You can override too but I don't recommend.) - @override - String toString() { - final Map params = {}; - - params['oauth_nonce'] = DateTime.now().millisecondsSinceEpoch.toString(); - params['oauth_signature_method'] = _signatureMethod.name; - params['oauth_timestamp'] = - (DateTime.now().millisecondsSinceEpoch / 1000).floor().toString(); - params['oauth_consumer_key'] = _clientCredentials.token; - params['oauth_version'] = '1.0'; - if (_credentials != null) { - params['oauth_token'] = _credentials.token; - } - params.addAll(_additionalParameters); - if (!params.containsKey('oauth_signature')) { - params['oauth_signature'] = _createSignature(_method, _url, params); - } - - final String authHeader = 'OAuth ' + - params.keys.map((String k) { - return '$k="${Uri.encodeComponent(params[k])}"'; - }).join(', '); - return authHeader; - } - - /// Create signature in ways referred from - /// https://dev.twitter.com/docs/auth/creating-signature. - String _createSignature( - String method, String url, Map params) { - // Referred from https://dev.twitter.com/docs/auth/creating-signature - if (params.isEmpty) { - throw ArgumentError('params is empty.'); - } - final Uri uri = Uri.parse(url); - - // - // Collecting parameters - // - - // 1. Percent encode every key and value - // that will be signed. - final Map encodedParams = {}; - params.forEach((String k, String v) { - encodedParams[Uri.encodeComponent(k)] = Uri.encodeComponent(v); - }); - uri.queryParameters.forEach((String k, String v) { - encodedParams[Uri.encodeComponent(k)] = Uri.encodeComponent(v); - }); - params.remove('realm'); - - // 2. Sort the list of parameters alphabetically[1] - // by encoded key[2]. - final List sortedEncodedKeys = encodedParams.keys.toList()..sort(); - - // 3. For each key/value pair: - // 4. Append the encoded key to the output string. - // 5. Append the '=' character to the output string. - // 6. Append the encoded value to the output string. - // 7. If there are more key/value pairs remaining, - // append a '&' character to the output string. - final String baseParams = sortedEncodedKeys.map((String k) { - return '$k=${encodedParams[k]}'; - }).join('&'); - - // - // Creating the signature base string - // - - final StringBuffer base = StringBuffer(); - // 1. Convert the HTTP Method to uppercase and set the - // output string equal to this value. - base.write(method.toUpperCase()); - - // 2. Append the '&' character to the output string. - base.write('&'); - - // 3. Percent encode the URL origin and path, and append it to the - // output string. - base.write(Uri.encodeComponent(uri.origin + uri.path)); - - // 4. Append the '&' character to the output string. - base.write('&'); - - // 5. Percent encode the parameter string and append it - // to the output string. - base.write(Uri.encodeComponent(baseParams.toString())); - - // - // Getting a signing key - // - - // The signing key is simply the percent encoded consumer - // secret, followed by an ampersand character '&', - // followed by the percent encoded token secret: - final String consumerSecret = - Uri.encodeComponent(_clientCredentials.tokenSecret); - final String tokenSecret = _credentials != null - ? Uri.encodeComponent(_credentials.tokenSecret) - : ''; - final String signingKey = '$consumerSecret&$tokenSecret'; - - // - // Calculating the signature - // - return _signatureMethod.sign(signingKey, base.toString()); - } -} diff --git a/lib/src/authorization_header_builder.dart b/lib/src/authorization_header_builder.dart deleted file mode 100644 index ae2e0a9..0000000 --- a/lib/src/authorization_header_builder.dart +++ /dev/null @@ -1,50 +0,0 @@ -library auhthorization_header_builder; - -import 'authorization_header.dart'; -import 'signature_method.dart'; -import 'client_credentials.dart'; -import 'credentials.dart'; - -/// A builder class for AuthorizationHeader -class AuthorizationHeaderBuilder { - SignatureMethod _signatureMethod; - ClientCredentials _clientCredentials; - Credentials _credentials; - String _method; - String _url; - Map _additionalParameters; - - AuthorizationHeaderBuilder(); - AuthorizationHeaderBuilder.from(AuthorizationHeaderBuilder other) - : _signatureMethod = other._signatureMethod, - _clientCredentials = other._clientCredentials, - _credentials = other._credentials, - _method = other._method, - _url = other._url, - _additionalParameters = other._additionalParameters; - - set signatureMethod(SignatureMethod value) => _signatureMethod = value; - set clientCredentials(ClientCredentials value) => _clientCredentials = value; - set credentials(Credentials value) => _credentials = value; - set method(String value) => _method = value; - set url(String value) => _url = value; - set additionalParameters(Map value) => - _additionalParameters = value; - - AuthorizationHeader build() { - if (_signatureMethod == null) { - throw StateError('signatureMethod is not set'); - } - if (_clientCredentials == null) { - throw StateError('clientCredentials is not set'); - } - if (_method == null) { - throw StateError('method is not set'); - } - if (_url == null) { - throw StateError('url is not set'); - } - return AuthorizationHeader(_signatureMethod, _clientCredentials, - _credentials, _method, _url, _additionalParameters); - } -} diff --git a/lib/src/authorization_request.dart b/lib/src/authorization_request.dart new file mode 100644 index 0000000..afd37eb --- /dev/null +++ b/lib/src/authorization_request.dart @@ -0,0 +1,1042 @@ +library authorization_request; + +import 'dart:convert'; +import 'dart:math'; + +import 'signature_method.dart'; +import 'client_credentials.dart'; +import 'credentials.dart'; +import 'exceptions.dart'; + +//################################################################ +/// Information in an OAuth request. +/// +/// Represents all the information that is signed by an OAuth signature, plus +/// the signature. This information consists of: +/// +/// - the HTTP request method ("POST", "GET", etc.); +/// - the requested URI; and +/// - parameters, both: +/// - OAuth protocol parameters and +/// - parameters that are not a part of the OAuth protocol. +/// +/// These parameters can be transmitted in an HTTP request as: +/// - an HTTP "Authorization" header; +/// - query parameters; and/or +/// - parameters in an "application/x-www-form-urlencoded" body. +/// +/// The OAuth protocol parameters (those starting with "oauth_*") have at most +/// one value, but other parameters may have multiple values for the same name. +/// The name and values of parameters are both case-sensitive. +/// +/// An OAuth1 _client_ should create an empty [AuthorizationRequest()] and +/// populate it with any non-OAuth protocol parameters from the body of the +/// HTTP request (if there is one); and optionally the _oauth_version_ if it +/// wants to transmit it. Then use the [sign] method to generate all the +/// OAuth protocol parameters. Any query parameters in the URI passed to the +/// _sign_ method are added to the signed parameters. The OAuth protocol +/// parameters can then be included in the HTTP request. The most simplest way +/// to do this is to use the [headerValue] and put that value into a HTTP +/// "Authorization" header. +/// +/// An OAuth1 _server_ should use [AuthorizationRequest.fromHttpRequest] to +/// gather all the signed information from the HTTP request. The information is +/// then used to identify the client and lookup the client credentials, and +/// (depending on the endpoint) also identify and lookup temporary credentials +/// or tokens. +/// With the credentials, the signature from the request can be checked using +/// the [validate] method. If the signature is not valid, the HTTP request +/// should be rejected and an error response produced, otherwise the HTTP +/// request can be processed to produce a suitable response. + +class AuthorizationRequest { + // Note: this class was previously called "AuthorizationHeader", but has been + // renamed to be more accurate, since the OAuth protocol parameters do not + // have to appear in an Authorization header. It has also been expanded to + // also track the method and URI, since these are needed for signing and + // validation. + + //================================================================ + // Constructors + + //---------------------------------------------------------------- + /// Default constructor. + /// + /// Creates an empty [AuthorizationRequest]. It has no parameters, and the + /// method, URI and realms are not set. + /// + /// This is used by OAuth1 clients to create an Authorization header. + /// + /// Typical clients can use the high-level protocol methods, instead of + /// using this constructor directly. Those high-level protocol methods will + /// use this constructor, populate, signs and encodes the parameters. + + AuthorizationRequest(); + + //---------------------------------------------------------------- + /// Constructor from information obtained from a HTTP request. + /// + /// This method is used by OAuth1 servers, to process an OAuth protocol HTTP + /// request. + /// + /// Pass in the request's HTTP [method], the requested [uri], all the + /// HTTP "Authorization" headers in [authorizationHeaders] and the contents + /// of the body (if the request has a MIME type of + /// application/x-www-form-urlencoded". + /// + /// There can be multiple Authorization headers. It is safe to pass them + /// all to this constructor, since it only processes those whose scheme is + /// for OAuth (any other headers are ignored). + /// + /// If there is no body, null or the empty string can be passed in for the + /// _urlEncodedBody_. + /// + /// Throws [FormatException] if the headers cannot be parsed. + /// Note: this constructor does not check any of the OAuth protocol parameters + /// for correctness: that is done when/if the [validate] method is invoked on + /// the object. + /// + /// Note: this method does not take a HttpRequest as a parameter, because + /// that would require importing the "dart:io" library and that would prevent + /// this library from being used in some situations. Implementations of an + /// OAuth servers will need to extra the necessary value from the HttpRequest + /// and pass them to this constructor. See _example_server.dart_ for how + /// what needs to be done. + + AuthorizationRequest.fromHttpRequest(String method, Uri uri, + Iterable authorizationHeaders, String urlEncodedBody) { + _setFromMethodAndUri(method, uri); + + // Incorporate any Authorization headers + + _realms = []; + + if (authorizationHeaders != null) { + for (final String value in authorizationHeaders) { + final String r = _parseAuthorizationHeader(value); // populates _params + if (r != null) { + _realms.add(r); // tracks the realms, in case the server wants them + } + } + } + + if (urlEncodedBody != null) { + // TODO: properly detect and handle encoding (it is not necessarily UTF-8) + addAll(_splitQueryStringAll(urlEncodedBody, encoding: utf8)); + } + } + + //================================================================ + // Static members + + // Standard OAuth1 parameter names + + static const String oauth_nonce = 'oauth_nonce'; + static const String oauth_signature_method = 'oauth_signature_method'; + static const String oauth_timestamp = 'oauth_timestamp'; + static const String oauth_consumer_key = 'oauth_consumer_key'; + static const String oauth_version = 'oauth_version'; + static const String oauth_token = 'oauth_token'; + static const String oauth_signature = 'oauth_signature'; + static const String oauth_verifier = 'oauth_verifier'; + static const String oauth_callback = 'oauth_callback'; + + /// Scheme for an OAuth Authorization header + /// + /// Note: this value is case insensitive (see section 3.5.1 of RFC 5849). + + static const String scheme = 'OAuth'; + + /// The version string "1.0" + /// + /// In OAuth1, the oauth_version parameter is optional. But if it is present, + /// it must always have this value. + /// + /// This value will never change. Programs may want to use this constant, + /// instead of having literal string values of "1.0" throughout the code + /// (and risking a mistake). + + static const String supportedVersion = '1.0'; + + /// Alphabet from which the nonce is generated from. + + static const String _nonceChars = + '23456789abcdefghjkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ'; + + /// Minimum nonce length. + /// + /// The nonce cannot be shorter than this length. + + static const int _minNonceLength = 4; + + static const int _defaultNonceLength = 8; + + //================================================================ + // Static members + + /// Length of generated strings for the nonce. + /// + /// If the [sign] method needs to generate a nonce, this is the length of the + /// random string generated (if it is not rejected as being too small). + + static int nonceLength = _defaultNonceLength; + + /// Random number generator used to generate nonce values. + + static final Random _nonceRnd = Random(DateTime.now().millisecondsSinceEpoch); + + //================================================================ + // Members + + String _method; + + Uri _urlWithoutQuery; // all query parameters are in [_params] + + final Map> _params = >{}; + + List _realms; + + //================================================================ + // Methods to retrieve values + + //---------------------------------------------------------------- + + /// Retrieves the HTTP method for the OAuth information. + /// + /// Returns null if the OAuth information has not been signed. + + String get method => _method; + + //---------------------------------------------------------------- + /// Retrieves the URI that was signed. + /// + /// Any query parameters that were a part of the original URI are not + /// included. They can be found in with the rest of the parameters. + /// + /// Returns null if the OAuth information has not been signed. + + Uri get uri => _urlWithoutQuery; + + //---------------------------------------------------------------- + /// Retrieve all parameters. + /// + /// This includes both the OAuth protocol parameters (those whose name starts + /// with "oauth_") and other parameters. + + Map> get parameters => _params; + + //---------------------------------------------------------------- + /// Retrieves all the values of a named parameter. + /// + /// Retrieves all the values of the parameter named [name]. + /// + /// Returns null if no values are set for the parameter. + + Iterable get(String name) => _params[name]; + + //---------------------------------------------------------------- + /// Retrieve all the OAuth protocol parameters for an Authorization header. + /// + /// Produces a value suitable for representing the OAuth protocol parameters + /// in an HTTP "Authorization" header. An optional [realm] can be included. + /// + /// For example, it will produce a string that starts with: + /// + /// OAuth realm="https://example.com", oauth_consumer_key="...", ... + /// + /// Only the OAuth protocol parameters (those starting with "oauth_") are + /// included. All other parameters are not included. The should be included + /// in the HTTP request by other means (i.e. as query parameters or in the + /// body). + /// + /// Throws a [StateError] if the header has not been signed, since it probably + /// does not make sense to use an OAuth Authorization header that is not + /// signed. If it is needed, get the OAuth protocol parameters using + /// [oauthParams] to build the value. + + String headerValue({String realm}) { + if (signature != null) { + final String optionalRealm = + (realm != null) ? 'realm="${Uri.encodeComponent(realm)}", ' : ''; + + final List components = []; + oauthParams().forEach((String k, String v) => + components.add('$k="${Uri.encodeComponent(v)}"')); + + return '$scheme $optionalRealm${components.join(', ')}'; + } else { + throw StateError('OAuth parameters have not been signed'); + } + } + + //---------------------------------------------------------------- + /// Retrieve all the OAuth protocol parameters. + /// + /// This can be used if the client wants to transmit the parameters using + /// an alternative mechanism (e.g. query parameters or in the body). Normally, + /// clients can simply use the [headerValue] to obtain all the OAuth protocol + /// parameters as a string value for insertion into the HTTP header as an + /// "Authorization" header. + /// + /// Throws [MultiValueParameter] if there are multiple values for any of + /// the OAuth protocol parameters, since there can be at most only one of + /// them. + + Map oauthParams() { + final Map result = {}; + + for (final String name in _params.keys) { + if (name.startsWith('oauth_')) { + final List values = _params[name]; + if (values.length == 1) { + result[name] = values.first; // use the first and only value + } else { + assert(values.isEmpty, '_param values should never be an empty list'); + throw MultiValueParameter(name); + } + } + } + + return result; + } + + //---------------------------------------------------------------- + /// Gets a single OAuth protocol parameter. + /// + /// Throws a [MultiValueParameter] if there are multiple values, since OAuth + /// protocol parameters can have at most one value. + /// + /// This is used internally to implement the getters. Programs using this + /// library should use the getters or [oauthParams]. + + String _oauthValue(String name) { + if (_params.containsKey(name)) { + final List values = _params[name]; + if (values.length == 1) { + return values.first; + } else if (values.isNotEmpty) { + throw MultiValueParameter(name); + } else { + return null; + } + } else { + return null; + } + } + + //---------------------------------------------------------------- + /// The realms from the Authorization headers. + /// + /// If the information was created by [AuthorizationRequest.fromHttpRequest], + /// this will be non-null. Its value will be the realms extracted from each of + /// the OAuth Authorization headers that were processed. If the OAuth + /// Authorization header did not have a realm, the value will be an empty + /// string. + /// + /// Note: there will be fewer members than the number of Authorization headers + /// passed into the [AuthorizationRequest.fromHttpRequest] method, if some of + /// those were not for the OAuth scheme. + + Iterable get realms => _realms; + + //================================================================ + // Methods for retrieving OAuth1 protocol parameters + + /// oauth_consumer_key parameter. + /// + /// This value identifies the client. The term "consumer key" comes from an + /// older version of the OAuth 1.0 specification. + /// + /// Value is null if the `oauth_consumer_key` parameter is not set. + + String get clientIdentifier => _oauthValue(oauth_consumer_key); + + /// oauth_token paraemter. + /// + /// Token identifier or temporary credential identifier. + /// + /// Value is null if the `oauth_token` parameter is not set. + + String get token => _oauthValue(oauth_token); + + /// oauth_verifier parameter. + /// + /// Value is null if the `oauth_verifier` parameter is not set. + + String get verifier => _oauthValue(oauth_verifier); + + /// oauth_callback parameter. + /// + /// Value is null if the `oauth_callback` parameter is not set. + + String get callback => _oauthValue(oauth_callback); + + /// oauth_signature_method parameter. + /// + /// Value is null if the `oauth_signature_method` parameter is not set. + + String get signatureMethod => _oauthValue(oauth_signature_method); + + /// oauth_nonce parameter. + /// + /// Value is null if the `oauth_nonce` parameter is not set. + + String get nonce => _oauthValue(oauth_nonce); + + /// oauth_timestamp parameter. + /// + /// Note: OAuth defines the timestamp as the string representation of a + /// positive integer. The value is usually the number of seconds since an + /// epoch, but it does not have to be that: section 3.3 of RFC 5849 says the + /// server documentation could define what it requires for the timestamp. + /// + /// Value is null if the `oauth_timestamp` parameter is not set. + + String get timestamp => _oauthValue(oauth_timestamp); + + /// oauth_signature parameter. + + String get signature => _oauthValue(oauth_signature); + + /// oauth_version parameter. + /// + /// This parameter is optional (i.e. can be null), but if set its value + /// must be "1.0" (the [supportedVersion] constant is that value). + + String get version => _oauthValue(oauth_version); + + //================================================================ + // Methods to set/modify the values + + //---------------------------------------------------------------- + /// Sets a parameter to a single value. + /// + /// Sets the parameter named [name] to have a single [value]. If the parameter + /// had any value or values, they are all discarded and replaced with just + /// the new value. + + void set(String name, String value) { + _params[name] = [value]; + } + + //---------------------------------------------------------------- + /// Adds a single value to a parameter. + /// + /// Add the [value] to the existing values of [name]. If there were no values + /// for the parameter, the value becomes its first and only value. If there + /// were values for the parameter, they are all kept and the new value is + /// added to them (even if the same value already exists for that name). + /// + /// All parameters can have multiple values for the same name, except for + /// those with names starting with "oauth_". A [MultiValueParameter] is thrown + /// if trying to add a second value to OAuth protocol parameters. + + void add(String name, String value) { + if (!_params.containsKey(name)) { + _params[name] = []; + } else { + if (name.startsWith('oauth_')) { + throw MultiValueParameter(name); + } + } + _params[name].add(value); + } + + //---------------------------------------------------------------- + /// Adds a set of parameters. + /// + /// Adds all the parameters in [extra] to the existing parameters. + /// If a parameter already has value(s), the new values are added to them. + /// + /// Throws [MultiValueParameter] if the extra parameters contains multiple + /// values for an OAuth protocol parameter, or contains one where a value + /// already exists for it. + + void addAll(Map> extra) { + extra.forEach((String name, Iterable values) { + if (values.isNotEmpty) { + bool hasExistingValues = true; + if (!_params.containsKey(name)) { + _params[name] = []; + hasExistingValues = false; + } + + if ((hasExistingValues || 1 < values.length) && + name.startsWith('oauth_')) { + throw MultiValueParameter(name); + } + + _params[name].addAll(values); + } + }); + } + + //---------------------------------------------------------------- + /// Removes all values of a parameter. + + void remove(String name) { + _params.remove(name); + } + + //---------------------------------------------------------------- + // Internal method used by [AuthenticationRequest.fromHttpRequest] and [sign]. + // + // Sets the [method] and the [uri] is used to set parameters from its + // query strings with the rest of it going to the the [_urlWithoutQuery]. + + void _setFromMethodAndUri(String method, Uri uri) { + _method = method.toUpperCase(); + + // Save the URI without the query parameters + + _urlWithoutQuery = Uri( + scheme: uri.scheme, + userInfo: uri.userInfo, + host: uri.host, + port: uri.port, + pathSegments: uri.pathSegments, + fragment: uri.hasFragment ? uri.fragment : null); + + // Incorporate any query parameters from the URI into the parameters + + addAll(uri.queryParametersAll); + } + + //================================================================ + // Methods to create and validate signatures + + //---------------------------------------------------------------- + /// Signs the OAuth information. + /// + /// Sets the [method] and [uri] (adding any query parameters in the URL to + /// the set of parameters) and calculates a signature over all the + /// information. The signature is stored in the object as the + /// _oauth_signature_ parameter. + /// + /// The signature is produced using the [signatureMethod], the client's + /// [clientCredentials]. The client credentials must be suitable for signing + /// with the signature method (i.e. it must have an RSA private key for + /// signing with RSA-SHA1, and a shared secret for signing with HMAC-SHA1 or + /// PLAINTEXT). Depending on which OAuth request is being made, the optional + /// [tokenCredentials] might be mandatory. + /// + /// If provided, it uses the [timestamp] for the timestamp. Otherwise, the + /// current number of seconds since 1970-01-01 00:00Z is used. + /// + /// If provided, the [nonce] is used for the nonce. Otherwise, a random string + /// of length [nonceLength] is generated for it (unless that length is deemed + /// too small to be secure, in which case a default length is used). + /// + /// This method will set (i.e. remove any/all previous values) for: + /// + /// - oauth_consumer_key; + /// - oauth_signature_method; + /// - oauth_timestamp; + /// - oauth_nonce; + /// - oauth_signature; and + /// - depending on if _tokenCredentials_ is provided or not, the oauth_token + /// is either set or removed. + /// + /// All other parameters are unchanged. If the HTTP request will + /// have other parameters (i.e. oauth_callback, oauth_verifier, oauth_version + /// or any other non-OAuth parameters - from URL query parameters and/or + /// a url-encoded body), they must be setup before invoking this signing + /// method. + /// + /// Most programs should ignore the return value. For debugging the internal + /// implementation of OAuth, the _signature base string_ that was signed + /// is returned. That value is not transmitted in the OAuth protocol. + + String sign(String method, Uri uri, ClientCredentials clientCredentials, + SignatureMethod signatureMethod, + {Credentials tokenCredentials, int timestamp, String nonce}) { + _setFromMethodAndUri(method, uri); + + if (version != null && version != supportedVersion) { + // While oauth_version is optional, if present its value must be "1.0". + // (from Section 3.1 or RFC 5849) + throw BadParameterValue('unsupported', oauth_version, version); + } + + int timestampToUse; + if (timestamp == null) { + // Use current time as a timestamp + timestampToUse = (DateTime.now().millisecondsSinceEpoch / 1000).floor(); + } else { + if (timestamp <= 0) { + throw ArgumentError.value(timestamp, 'timestamp', 'must be a +ve int'); + } + timestampToUse = timestamp; // use provided timestamp + } + + String nonceToUse; + if (nonce == null) { + // Generate a nonce + nonceToUse = _generateNonce(); + } else { + if (nonce.isEmpty) { + throw ArgumentError.value(nonce, 'nonce', 'empty string not sensible'); + } + nonceToUse = nonce; // use provided nonce + } + + // Set parameters that are needed for a properly signed header + + set(oauth_consumer_key, clientCredentials.identifier); + set(oauth_signature_method, signatureMethod.name); + set(oauth_timestamp, timestampToUse.toString()); + set(oauth_nonce, nonceToUse); + + if (tokenCredentials != null) { + set(oauth_token, tokenCredentials.token); + } else { + remove(oauth_token); + } + + // Create the signature and set it (i.e. replacing any existing signature) + + final String signatureBaseString = _baseString(); + + final String theSignature = signatureMethod.sign( + signatureBaseString, clientCredentials, tokenCredentials?.tokenSecret); + + set(oauth_signature, theSignature); + + return signatureBaseString; // for debugging (not needed by the protocol) + } + + //---------------------------------------------------------------- + /// Validates the correct OAuth protocol parameters and the signature. + /// + /// The [clientCredentials] are used to validate the request, and must be + /// for the client that signed the request (i.e. the client identity are the + /// same) and be suitable for the signature method (i.e. has an RSA public key + /// for RSA-SHA1, otherwise a has a shared secret). + /// + /// The optional [tokenSecret] is required or prohibited, depending on the + /// particular OAuth request being validated. Note: this _validate_ method + /// only requires the shared secret, but the _sign_ method uses both the token + /// and the shared secret (which is why its argument is a _Credentials_ + /// instead of only the String shared secret). + /// + /// Implements section 3.2 of RFC 5849 + /// , except for the scope + /// and status of the token, if present. + /// + /// Will throw a [BadOAuth] if the request is not valid. + /// Otherwise, no exception is thrown if the validation succeeds. + /// + /// Most programs should ignore the return value. For debugging the internal + /// implementation of OAuth, the _signature base string_ that was calculated + /// from the parameters is returned. That value is not transmitted in the + /// OAuth protocol. + + String validate(ClientCredentials clientCredentials, [String tokenSecret]) { + if (_method == null || _urlWithoutQuery == null) { + throw StateError('cannot validate: not signed or not from a HttpRequest'); + } + + //-------- + // Check parameters are correct according to section 3.1 of RFC 5849 + // + // + // Note: these checks use the getters, which will throw a [MultipleValues] + // exception if there are multiple values for the same parameter name. + + if (clientIdentifier == null) { + throw const MissingParameter(oauth_consumer_key); + } + if (clientIdentifier != clientCredentials.identifier) { + throw BadParameterValue('does not match client credentials', + oauth_consumer_key, clientIdentifier); + } + + if (signatureMethod == null) { + throw const MissingParameter(oauth_signature_method); + } + SignatureMethod sigMethod; + final String smName = signatureMethod; + if (smName == SignatureMethods.hmacSha1.name) { + sigMethod = SignatureMethods.hmacSha1; + } else if (smName == SignatureMethods.rsaSha1.name) { + sigMethod = SignatureMethods.rsaSha1; + } else if (smName == SignatureMethods.plaintext.name) { + sigMethod = SignatureMethods.plaintext; + } else { + throw BadParameterValue('unsupported', oauth_signature_method, smName); + } + + if (signatureMethod != SignatureMethods.plaintext.name) { + // Timestamp and nonce are optional for the "PLAINTEXT" signature method, + // so is mandatory for everything else (i.e. for HMAC-SHA1 and RSA-SHA1) + + if (timestamp == null) { + throw const MissingParameter(oauth_timestamp); + } + if (!RegExp(r'^\d+$').hasMatch(timestamp)) { + throw BadParameterValue('not +ve integer', oauth_timestamp, timestamp); + } + + if (nonce == null) { + throw const MissingParameter(oauth_nonce); + } + if (nonce.isEmpty) { + throw BadParameterValue('empty nonce', oauth_nonce, nonce); + } + } + + // Check optional oauth_version. If present, it must be "1.0" + + final String v = version; + if (v != null && v != supportedVersion) { + throw BadParameterValue('unsupported version', oauth_version, version); + } + + // Check the signature is present + + if (signature == null) { + throw const MissingParameter(oauth_signature); + } + + // Calculate the signature base string and check the signature against it + + final String calculatedSignatureBaseString = _baseString(); + + if (!sigMethod.validate(signature, calculatedSignatureBaseString, + clientCredentials, tokenSecret)) { + throw SignatureInvalid( + calculatedSignatureBaseString); // validation failed + } + + // validation successful: method finishes without throwing an exception + + return calculatedSignatureBaseString; // for debugging, otherwise not needed + } + + //---------------------------------------------------------------- + /// Calculate the signature base string. + /// + /// The _signature base string_ is defined by section 3.4.1 of RFC 5849. + /// + /// + /// It is consistent, reproducible concatenation of several of the HTTP + /// request elements into a single string, and is used as an input to the + /// "HMAC-SHA1" and "RSA-SHA1" signature methods. + + String _baseString() { + assert(_method != null); + assert(_urlWithoutQuery != null); + + // Normalize parameters (as described in section 3.4.1.3.2 of RFC 5849) + + // 1. Percent encode every key and value that will be signed. + // + // Note: if the URI has query parameters, they will have already been + // included into _params. Any parameters from a urlencoded body will also + // have been already included into _params. Everything is in _params, + // includeing any "oauth_signature" which this code will skip. + + final Map> encodedParams = >{}; + + _params.forEach((String k, List values) { + if (k != oauth_signature) { + final List encValues = []; + + for (String v in values) { + encValues.add((v.isNotEmpty) ? _percentEncode(v) : ''); + } + + encodedParams[_percentEncode(k)] = encValues; + } + }); + + // 2. Sort the encoded parameters by name (using ascending byte value) + // and multiple values by value. + + for (final List values in encodedParams.values) { + values.sort(); // sort multiple values by their encoded value + } + final List sortedEncodedKeys = encodedParams.keys.toList()..sort(); + + // 3. For each key/value pair: + // 4. Append the encoded key to the output string. + // 5. Append the '=' character to the output string. + // 6. Append the encoded value to the output string. + // 7. If there are more key/value pairs remaining, + // append a '&' character to the output string. + + final String normalizedParams = sortedEncodedKeys.map((String k) { + final List sortedValues = encodedParams[k]; + return sortedValues.map((String v) => '$k=$v').join('&'); + }).join('&'); + + // + // Creating the signature base string + // + + final StringBuffer base = StringBuffer(); + // 1. Convert the HTTP Method to uppercase and set the + // output string equal to this value. + base.write(_percentEncode(_method)); // note: already in uppercase + + // 2. Append the '&' character to the output string. + base.write('&'); + + // 3. Percent encode the _Base String URI_ + + final String schemeLC = _urlWithoutQuery.scheme.toLowerCase(); + final String hostLC = _urlWithoutQuery.host.toLowerCase(); + final String portStr = (schemeLC == 'http' && _urlWithoutQuery.port == 80 || + schemeLC == 'https' && _urlWithoutQuery.port == 443) + ? '' + : ':${_urlWithoutQuery.port}'; + + base.write( + _percentEncode('$schemeLC://$hostLC$portStr${_urlWithoutQuery.path}')); + + // 4. Append the '&' character to the output string. + base.write('&'); + + // 5. Percent encode the parameter string and append it + // to the output string. + base.write(_percentEncode(normalizedParams)); + + // Return the base string + // + return base.toString(); + } + + //---------------- + // Percent encoding for signature base string from section 3.6 of RFC 5849. + // + // String is encoded as UTF-8: and safe characters are not encoded, but + // all other characters are percent encoded (with uppercase hex characters). + // + // Note: this is NOT the same as the percent encoding implemented by RFC3986, + // so the standard Uri.encode... methods cannot be used. + + static final Map _notEncodedCharcodes = + Map.fromIterable( + 'abcdefghijklmnopqrstuvwxyz' + 'ABCDEFGHIJKLMNOPQRSTUVWXYZ' + '0123456789' + '-._~' + .codeUnits, + key: (dynamic c) => c, + value: (dynamic c) => true); + + static const List _hexDigits = [ + '0', + '1', + '2', + '3', + '4', + '5', + '6', + '7', + '8', + '9', + 'A', + 'B', + 'C', + 'D', + 'E', + 'F' + ]; + + static String _percentEncode(String str) => utf8.encode(str).map((int c) { + if (_notEncodedCharcodes.containsKey(c)) { + return String.fromCharCode(c); + } else { + return '%${_hexDigits[(c >> 4) & 0x0F]}${_hexDigits[c & 0x0F]}'; + } + }).join(); + + //---------------------------------------------------------------- + /// Generate a random string for use as a nonce. + + String _generateNonce() { + final StringBuffer buf = StringBuffer(); + final int len = + (_minNonceLength < nonceLength) ? nonceLength : _defaultNonceLength; + + for (int x = 0; x < len; x++) { + buf.write(_nonceChars[_nonceRnd.nextInt(_nonceChars.length)]); + } + return buf.toString(); + } + + //================================================================ + // Supporting method for parsing parameters + + //---------------------------------------------------------------- + /// Adds parameters from an OAuth authorization header. + /// + /// Parses the [str] as the value from an Authorization header. If it is not + /// an OAuth authorization header (i.e. the value does not start with the + /// "OAuth" scheme (case insensitive)) then false is returned and it is + /// ignored. + /// + /// True is returned if the value is an OAuth authorization header and the + /// parameters in it have been added. + /// + /// Throws [FormatException] if the string is not a valid authorization header + /// value. For example, if it does not start with "OAuth" or is missing + /// parameters such as "oauth_consumer_key", "oauth_nonce" and + /// "oauth_version". + /// + /// This is used when implementing an OAuth1 server, and is used to parse the + /// "Authorization" header received from the HTTP requests. After it has been + /// parsed, its values can be examined to identify the client and temporary + /// credentials or access token. After looking up the credentials for the + /// client (and optionally the shared secret of the temporary credential or + /// access token), the signature in the header can be validated by + /// invoking the [validate] method. If the signature is valid, the header + /// can then be used. + /// + /// Note: besides the Authorization header, an OAuth1 server may be passed + /// parameters from the body of the request and/or query parameters. + /// See sections 3.5.2 and 3.5.3 of RFC 5849. This library does not + /// include an implementation of that, since it would require importing + /// "dart:io" which would prevent this library from being used in the browser. + /// + /// Returns null if the header was not processed, because its scheme is not + /// for OAuth. Otherwise, returns the "realm" value or the empty string if + /// there wasn't a realm. + + String _parseAuthorizationHeader(String str) { + // The string must start with the scheme ("OAuth" case insensitive). + + final String matchLowercase = '$scheme '.toLowerCase(); // with the space + if (str.length < matchLowercase.length) { + return null; + } + final String start = str.substring(0, matchLowercase.length); + if (start.toLowerCase() != matchLowercase) { + return null; + } + + // Extract all the parameters from the rest of the string + + String realm = ''; // non-null value if there is no realm + + for (final String c in str.substring(scheme.length).split(',')) { + // Process each of the key="value" components + + final String component = c.trim(); + + // Split into key and the double quoted value + + final int equalsIndex = component.indexOf('='); + if (equalsIndex < 0) { + // Not found + throw FormatException('component is not name=value: $component'); + } else if (equalsIndex == 0) { + // No key (component is like "=something") + throw FormatException('component missing name: $component'); + } + final String name = component.substring(0, equalsIndex); + final String quotedValue = component.substring(equalsIndex + 1); + + // Remove the double quotes around the value and decode it + + if (quotedValue.length < 2 || + quotedValue[0] != '"' || + quotedValue[quotedValue.length - 1] != '"') { + throw FormatException('component value not double quoted: $component'); + } + + final String v = quotedValue.substring(1, quotedValue.length - 1); + final String value = Uri.decodeComponent(v); + + if (name != 'realm') { + add(name, value); // Save the key/value pair + } else { + realm = value; + } + } + + return realm; + } + + //================================================================ + // Standard object methods + + //---------------------------------------------------------------- + + @override + String toString() { + final StringBuffer buf = StringBuffer(); + + if (_method != null || _urlWithoutQuery != null) { + buf.write('$_method<$_urlWithoutQuery>'); + } + + buf.write('{'); + + bool first = true; + for (final String k in _params.keys) { + for (final String v in _params[k]) { + if (!first) { + buf.write(', '); + } + first = false; + buf.write('"$k":"$v"'); + } + } + + buf.write('}'); + + if (_realms != null) { + buf.write('; realms=[${_realms.join(', ')}]'); + } + + return buf.toString(); + + // Note: this string format is deliberately NOT the same as that used in the + // Authorization header value, so programs shouldn't accidentally use this + // for the wrong purpose. This method can be used to display incomplete + // information that might not have been signed. + } +} + +//---------------------------------------------------------------- +/// Parses an encoded query string +/// +/// Unlike [Uri.splitQueryString], this method supports multiple values with +/// the same name. + +Map> _splitQueryStringAll(String query, + {Encoding encoding}) { + return query.split('&').fold(>{}, + (Map> map, String element) { + String name; + String value; + + final int index = element.indexOf('='); + if (index == -1) { + // No equal sign: treat as name= (i.e. value is the empty string) + if (element.isNotEmpty) { + name = Uri.decodeQueryComponent(element, encoding: encoding); + value = ''; + } + } else if (index != 0) { + // Equal sign is present and is not the first character (name=value) + name = Uri.decodeQueryComponent(element.substring(0, index), + encoding: encoding); + + value = Uri.decodeQueryComponent(element.substring(index + 1), + encoding: encoding); + } + + if (name != null) { + assert(value != null); + if (!map.containsKey(name)) { + map[name] = []; // create a new list for the first value + } + map[name].add(value); // append to list of values + } + + return map; + }); +} diff --git a/lib/src/authorization_response.dart b/lib/src/authorization_response.dart index 2e19ad0..509b8eb 100644 --- a/lib/src/authorization_response.dart +++ b/lib/src/authorization_response.dart @@ -2,12 +2,22 @@ library authorization_response; import 'credentials.dart'; -/// A class describing Response of Authoriazation request. +/// A class describing Response of Authorization response. +/// +/// The identifier and shared-secret are stored in the [credentials] and +/// any other parameters are stored in the [optionalParameters]. +/// +/// This is used to represent the _temporary credential_ from the HTTP response +/// produced by the Temporary Credential Request endpoint; as well as the +/// _token credential_ from the HTTP response produced by the Token Request +/// endpoint. + class AuthorizationResponse { final Credentials _credentials; final Map _optionalParameters; AuthorizationResponse(this._credentials, this._optionalParameters); + factory AuthorizationResponse.fromMap(Map parameters) { final Map paramsCopy = Map.from(parameters); final Credentials cred = Credentials.fromMap(paramsCopy); diff --git a/lib/src/client.dart b/lib/src/client.dart index b9cef44..edb1b18 100644 --- a/lib/src/client.dart +++ b/lib/src/client.dart @@ -2,16 +2,18 @@ library oauth1_client; import 'dart:async'; import 'package:http/http.dart' as http; +import 'package:oauth1/oauth1.dart'; import 'signature_method.dart'; import 'client_credentials.dart'; import 'credentials.dart'; -import 'authorization_header_builder.dart'; /// A proxy class describing OAuth 1.0 Authenticated Request +/// /// http://tools.ietf.org/html/rfc5849#section-3 /// /// If _credentials is null, this is usable for authorization requests too. + class Client extends http.BaseClient { final SignatureMethod _signatureMethod; final ClientCredentials _clientCredentials; @@ -28,26 +30,34 @@ class Client extends http.BaseClient { @override Future send(http.BaseRequest request) { - final AuthorizationHeaderBuilder ahb = AuthorizationHeaderBuilder(); - ahb.signatureMethod = _signatureMethod; - ahb.clientCredentials = _clientCredentials; - ahb.credentials = _credentials; - ahb.method = request.method; - ahb.url = request.url.toString(); + final AuthorizationRequest auth = AuthorizationRequest(); + + auth.set(AuthorizationRequest.oauth_version, + AuthorizationRequest.supportedVersion); + + // Include additional parameters, from any Authorization header and + // any www-form-urlencoded body, so they are also signed as required by + // RFC 5849. + final Map headers = request.headers; - Map additionalParameters = {}; + if (headers.containsKey('Authorization')) { - additionalParameters = Uri.splitQueryString(headers['Authorization']); + final String str = headers['Authorization']; + Uri.splitQueryString(str).forEach((String k, String v) => auth.add(k, v)); } if (headers.containsKey('content-type') && headers['content-type'].contains('application/x-www-form-urlencoded') && (request as http.Request).body != null) { - additionalParameters - .addAll(Uri.splitQueryString((request as http.Request).body)); + final String str = (request as http.Request).body; + Uri.splitQueryString(str).forEach((String k, String v) => auth.add(k, v)); } - ahb.additionalParameters = additionalParameters; - request.headers['Authorization'] = ahb.build().toString(); + // Sign it and include it as an authorization header + + auth.sign(request.method, request.url, _clientCredentials, _signatureMethod, + tokenCredentials: _credentials); + + request.headers['Authorization'] = auth.headerValue(); return _httpClient.send(request); } } diff --git a/lib/src/client_credentials.dart b/lib/src/client_credentials.dart index 9a927c7..8ab8c98 100644 --- a/lib/src/client_credentials.dart +++ b/lib/src/client_credentials.dart @@ -1,12 +1,89 @@ library client_credentials; -/// A class describing OAuth client credentials. +import 'package:pointycastle/asymmetric/api.dart'; + +/// Client credentials. +/// +/// A client credential has an [identifier] and at least one or more: +/// - shared secret, +/// - RSA public key, and/or +/// - RSA private key. +/// +/// The shared secret is required for the +/// HMAC-SHA1 and PLAINTEXT signature methods. The RSA private key is required +/// to create RSA-SHA1 signatures. The RSA public key is required to validate +/// RSA-SHA1 signatures. + class ClientCredentials { - final String _token; - final String _tokenSecret; + /// Constructor for a set of client credentials. + /// + /// Creates a client credential for a client whose client identity is + /// [identity]. + /// + /// For backward compatibility, the [sharedSecret] parameter is a required + /// parameter to this constructor. But if there is no shared secret (i.e. the + /// client credentials only needs to work with RSA-SHA1), pass in null as the + /// shared secret. + + ClientCredentials(this.identifier, this.sharedSecret, + {this.publicKey, this.privateKey}) { + if (identifier == null) { + throw ArgumentError.notNull('token'); + } + + if (sharedSecret == null && publicKey == null && privateKey == null) { + // At least one of these must be provided (since even PLAINTEXT requires + // a shared secret). + throw ArgumentError('no shared secret, public or private key'); + } + } + + /// Deprecated name for the client identifier. + /// + /// Deprecated because the word "token" can be confused with the OAuth term + /// for the "access token". Token is not used in OAuth in relationship to the + /// client. OAuth terminology is confusing enough already! + /// Use [identifier] instead. + @deprecated + String get token => identifier; + + /// Deprecated name for the shared secret. + /// + /// Deprecated because the word "token" is confusing in this context. + /// Use [sharedSecret] instead. + @deprecated + String get tokenSecret => sharedSecret; + + /// Identifier for the client. + /// + /// Previous versions of the OAuth specification referred to this as the + /// "consumer key". Server documentation may call this something different, + /// suc as "API key" or "username". + + final String identifier; + + /// Shared secret for authenticating the client. + /// + /// If this value is null, the clent credentials cannot be used to sign or + /// validate requests using the HMAC-SHA1 or PLAINTEXT signature methods. + /// + /// Previous version of the OAuth specification referred to this as the + /// "consumer secret". Server documentation may call this something different, + /// such as "API secret" or "password". + + final String sharedSecret; + + /// RSA public key + /// + /// If this value is null, the client credentials cannot be used to validate + /// requests created using the RSA-SHA1 signature method. + + final RSAPublicKey publicKey; - ClientCredentials(this._token, this._tokenSecret); + /// RSA private key + /// + /// If this value is null, the client credentials cannot be used to sign + /// requests using the RSA-SHA1 signature method. - String get token => _token; - String get tokenSecret => _tokenSecret; + final RSAPrivateKey privateKey; } diff --git a/lib/src/credentials.dart b/lib/src/credentials.dart index def1b91..8f29018 100644 --- a/lib/src/credentials.dart +++ b/lib/src/credentials.dart @@ -2,12 +2,22 @@ library credentials; import 'dart:convert'; -/// A class describing OAuth credentials except for client credential +import 'package:oauth1/oauth1.dart'; + +/// Temporary credentials or token credentials. +/// +/// This class is used to represent _temporary credentials_ (also known as an +/// "authorization request") and _token credentials_ (also known as an +/// "access token" or "access grant"). +/// +/// The third type of OAuth credentials, _client credentials_, are not +/// represented by this class, but by the [ClientCredentials] class. + class Credentials { final String _token; final String _tokenSecret; - Credentials(this._token, this._tokenSecret); + const Credentials(this._token, this._tokenSecret); factory Credentials.fromMap(Map parameters) { if (!parameters.containsKey('oauth_token')) { throw ArgumentError("params doesn't have a key 'oauth_token'"); diff --git a/lib/src/exceptions.dart b/lib/src/exceptions.dart new file mode 100644 index 0000000..a33e06d --- /dev/null +++ b/lib/src/exceptions.dart @@ -0,0 +1,79 @@ +library oauth_exceptions; + +//################################################################ +/// Abstract base class for exceptions thrown by the OAuth library. + +abstract class BadOAuth implements Exception { + const BadOAuth(this.reason); + + /// Reason why the OAuth authorization request is not valid. + final String reason; + + @override + String toString() => 'OAuth invalid: $reason'; +} + +//================================================================ +// Exceptions indicating problems with the OAuth protocol parameters. +// +// These can occur when validating an OAuth request, as well as when updating +// or retrieving OAuth protocol parameters. + +//---------------------------------------------------------------- +/// An OAuth protocol parameter incorrectly has multiple values. + +class MultiValueParameter extends BadOAuth { + const MultiValueParameter(this.name) : super('parameter has multiple values'); + + /// Name of the parameter with multiple values. + final String name; + + @override + String toString() => '$reason: $name'; +} + +//---------------------------------------------------------------- +/// An OAuth protocol parameter has the wrong value. + +class BadParameterValue extends BadOAuth { + const BadParameterValue(String reason, this.name, this.value) : super(reason); + + /// Name of the parameter with a bad value. + final String name; + + /// The bad value. + final String value; + + @override + String toString() => '$reason: $name=$value'; +} + +//================================================================ +// Exceptions thrown when validating an OAuth request. + +//---------------------------------------------------------------- +/// An OAuth protocol parameter is required but is missing. + +class MissingParameter extends BadOAuth { + const MissingParameter(this.name) : super('required parameter is missing'); + + /// Name of the missing parameter. + final String name; + + @override + String toString() => '$reason: $name'; +} + +//---------------------------------------------------------------- +/// The signature is not valid. + +class SignatureInvalid extends BadOAuth { + SignatureInvalid(this.signatureBaseString) : super('signature invalid'); + + /// The _signature base string_ used for validating the signature. + /// + /// Most programs should ignore this member. This value is only useful for + /// debugging the internal implementation of OAuth. + + final String signatureBaseString; +} diff --git a/lib/src/platform.dart b/lib/src/platform.dart index 53aef4f..69702f3 100644 --- a/lib/src/platform.dart +++ b/lib/src/platform.dart @@ -3,7 +3,9 @@ library platform; import 'signature_method.dart'; /// Configuration of OAuth1Authorization. +/// /// http://tools.ietf.org/html/rfc5849 + class Platform { final String _temporaryCredentialsRequestURI; final String _resourceOwnerAuthorizationURI; diff --git a/lib/src/signature_method.dart b/lib/src/signature_method.dart index 94bff61..0ed6e77 100644 --- a/lib/src/signature_method.dart +++ b/lib/src/signature_method.dart @@ -1,44 +1,238 @@ library signature_method; import 'dart:convert'; +import 'dart:typed_data'; + import 'package:crypto/crypto.dart'; +import 'package:pointycastle/api.dart'; +import 'package:pointycastle/asymmetric/api.dart'; + +import 'package:oauth1/oauth1.dart'; -/// A class abstracting Signature Method. +//################################################################ +/// Implementation of a signature method. +/// +/// Instances of this class are used to implement particular signature methods. +/// /// http://tools.ietf.org/html/rfc5849#section-3.4 +/// +/// The library provides instances of this class for the standard OAuth +/// signature methods: +/// - [SignatureMethods.hmacSha1]; +/// - [SignatureMethods.rsaSha1]; and +/// - [SignatureMethods.plaintext]. + class SignatureMethod { - final String _name; - final String Function(String key, String text) _sign; + //================================================================ + // Constructors /// A constructor of SignatureMethod. - SignatureMethod(this._name, this._sign); + const SignatureMethod._internal( + this._name, this._signWithCredentials, this._validate); + + //================================================================ + // Members + + final String _name; + + final String Function(String signatureBaseString, ClientCredentials client, + [String tokenSecret]) _signWithCredentials; + + final bool Function(String signature, String signatureBaseString, + ClientCredentials client, String tokenSecret) _validate; + + //================================================================ + // Methods /// Signature Method Name String get name => _name; - /// Sign data by key. - String sign(String key, String text) => _sign(key, text); + //---------------------------------------------------------------- + /// Creates a signature. + /// + /// Returns the signature of the [signatureBaseString] when signed using the + /// [clientCredentials] and (if provided) the [tokenSecret]. + /// + /// Throws an [ArgumentError] if the client credentials are not suitable + /// for the signature method. For example, if using HMAC-SHA1 and PLAINTEXT + /// and the client credentials doesn't have a shared secret; or if using + /// RSA-SHA1 and the client credentials doesn't have an RSA private key. + + String sign(String signatureBaseString, ClientCredentials clientCredentials, + [String tokenSecret]) => + _signWithCredentials(signatureBaseString, clientCredentials, tokenSecret); + + //---------------------------------------------------------------- + /// Validates a signature. + /// + /// Returns true if the [signature] is valid, otherwise false. + /// + /// The signature is valid if it is a valid signature of the + /// [signatureBaseString] when validated with the [clientCredentials] and + /// (if provided) the [tokenSecret]. + /// + /// Throws an [ArgumentError] if the client credentials are not suitable + /// for the signature method. For example, if using HMAC-SHA1 and PLAINTEXT + /// and the client credentials doesn't have a shared secret; or if using + /// RSA-SHA1 and the client credentials doesn't have an RSA public key. + + bool validate(String signature, String signatureBaseString, + ClientCredentials clientCredentials, + [String tokenSecret]) => + _validate(signature, signatureBaseString, clientCredentials, tokenSecret); } -/// A abstract class contains Signature Methods. +//################################################################ +/// Standard signature methods. +/// +/// This class defines the standard [SignatureMethod] from OAuth1: +/// - HMAC-SHA1 (implemented by [hmacSha1]) +/// - RSA-SHA1 (implemented by [rsaSha1]) +/// - PLAINTEXT (implemented by [plaintext]). + abstract class SignatureMethods { - /// http://tools.ietf.org/html/rfc5849#section-3.4.2 - static final SignatureMethod hmacSha1 = - SignatureMethod('HMAC-SHA1', (String key, String text) { - final Hmac hmac = Hmac(sha1, key.codeUnits); - final List bytes = hmac.convert(text.codeUnits).bytes; - - // The output of the HMAC signing function is a binary - // string. This needs to be base64 encoded to produce - // the signature string. - return base64.encode(bytes); - }); - - /// http://tools.ietf.org/html/rfc5849#section-3.4.3 - /// TODO: Implement RSA-SHA1 - - /// http://tools.ietf.org/html/rfc5849#section-3.4.4 - static final SignatureMethod plaintext = - SignatureMethod('PLAINTEXT', (String key, String text) { - return key; - }); + //================================================================ + // Standard signature methods + + //---------------------------------------------------------------- + /// Implements the HMAC-SHA1 signature method. + + static const SignatureMethod hmacSha1 = + SignatureMethod._internal('HMAC-SHA1', _hmacSign, _hmacVerify); + + //---------------------------------------------------------------- + /// Implements the RSA-SHA1 signature method. + + static const SignatureMethod rsaSha1 = + SignatureMethod._internal('RSA-SHA1', _rsaSign, _rsaVerify); + + //---------------------------------------------------------------- + /// Implements the PLAINTEXT signature method. + + static const SignatureMethod plaintext = + SignatureMethod._internal('PLAINTEXT', _plainSign, _plainVerify); + + //================================================================ + // Methods used for the standard signature methods. + // + // These have been separated from the above, so the above section is more + // easy to read. + + //---------------------------------------------------------------- + // Methods for HMAC-SHA1 + // http://tools.ietf.org/html/rfc5849#section-3.4.2 + + static String _hmacSign( + String signatureBaseString, ClientCredentials clientCredentials, + [String tokenSecret]) { + return _hmacSignature(signatureBaseString, clientCredentials, tokenSecret); + } + +//---------------- + + static bool _hmacVerify( + String signature, + String signatureBaseString, + ClientCredentials clientCredentials, + String tokenSecret, + ) { + final String expected = + _hmacSignature(signatureBaseString, clientCredentials, tokenSecret); + + return expected == signature; + } + +//---------------- + + static String _hmacSignature(String signatureBaseString, + ClientCredentials clientCredentials, String tokenSecret) { + final Hmac hmac = + Hmac(sha1, _concatKeys(clientCredentials, tokenSecret).codeUnits); + return base64.encode(hmac.convert(signatureBaseString.codeUnits).bytes); + } + + //---------------------------------------------------------------- + // Methods for RSA-SHA1 + + static String _rsaSign( + String signatureBaseString, ClientCredentials clientCredentials, + [String tokenSecret]) { + /// Signing involves encrypting with the private key + if (clientCredentials.privateKey == null) { + throw ArgumentError.value(clientCredentials, 'clientCredentials', + 'not suitable for RSA-SHA1: no RSA private key'); + } + + final Signer signer = Signer('SHA-1/RSA'); + + signer.init( + true, PrivateKeyParameter(clientCredentials.privateKey)); + + final Signature sig = + signer.generateSignature(ascii.encode(signatureBaseString)); + if (sig is RSASignature) { + return base64.encode(sig.bytes); + } else { + throw StateError('Signer did not produce a RSASignature'); + } + } + + //---------------- + + static bool _rsaVerify(String signature, String signatureBaseString, + ClientCredentials clientCredentials, String tokenSecret) { + // Validating an RSA-SHA1 signature involves + if (clientCredentials.publicKey == null) { + throw ArgumentError.value(clientCredentials, 'clientCredentials', + 'not suitable for RSA-SHA1: no RSA public key'); + } + + final Uint8List sig = base64.decode(signature); + + final Signer signer = Signer('SHA-1/RSA'); + signer.init( + false, PublicKeyParameter(clientCredentials.publicKey)); + return signer.verifySignature( + ascii.encode(signatureBaseString), RSASignature(sig)); + } + + //---------------------------------------------------------------- + // Methods for PLAINTEXT + // http://tools.ietf.org/html/rfc5849#section-3.4.4 + + static String _plainSign( + String signatureBaseString, ClientCredentials clientCredentials, + [String tokenSecret]) { + return _concatKeys(clientCredentials, tokenSecret); + } + + //---------------- + + static bool _plainVerify(String signature, String signatureBaseString, + ClientCredentials clientCredentials, String tokenSecret) { + final String expected = _concatKeys(clientCredentials, tokenSecret); + return expected == signature; + } + + //---------------------------------------------------------------- + /// Common method used by both HMAC-SHA1 and PLAINTEXT signing methods. + /// + /// This method implements the "key" for HMAC-SHA1 as defined in section 3.4.2 + /// of RFC 5849, and the value for the oauth_signature for PLAINTEXT as + /// defined in section 3.4.4. + + static String _concatKeys( + ClientCredentials clientCredentials, String tokenSecret) { + if (clientCredentials.sharedSecret == null) { + throw ArgumentError.value(clientCredentials, 'clientCredentials', + 'not suitable for signature method: no shared secret'); + } + + final String consumerPart = + Uri.encodeComponent(clientCredentials.sharedSecret); + final String tokenPart = + tokenSecret != null ? Uri.encodeComponent(tokenSecret) : ''; + + return '$consumerPart&$tokenPart'; + } } diff --git a/pubspec.yaml b/pubspec.yaml index 933b218..f73685b 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,15 +1,17 @@ name: oauth1 -version: 1.0.4 -description: "\"RFC 5849: The OAuth 1.0 Protocol\" client implementation for Dart." +version: 2.0.0-alpha +description: "OAuth1 clients and servers conforming to \"RFC 5849: The OAuth 1.0 Protocol\"." homepage: https://github.com/nbspou/dart-oauth1 author: kumar8600 environment: - sdk: '>=2.0.0-dev.58.0 <3.0.0' + sdk: '>=2.0.0 <3.0.0' dependencies: crypto: ^2.0.0 + encrypt: ^3.2.0 http: '>=0.11.0 < 0.13.0' dev_dependencies: + args: ^1.5.1 test: ^1.0.0 diff --git a/test/authorization_request_test.dart b/test/authorization_request_test.dart new file mode 100644 index 0000000..9480b4d --- /dev/null +++ b/test/authorization_request_test.dart @@ -0,0 +1,573 @@ +import 'dart:convert'; + +import 'package:oauth1/oauth1.dart' as prefix0; +import 'package:test/test.dart'; +import 'package:oauth1/oauth1.dart'; +import 'package:encrypt/encrypt.dart'; + +//################################################################ + +void main() { + group('signature base string', () { + //================================================================ + + group('example from section 3.4.1.1 of RFC 5849', () { + // Tests the "signature base string" matches the example value from + // Section 3.4.1.1 of RFC 5849 + // + + const String expectedSBS = + 'POST&http%3A%2F%2Fexample.com%2Frequest&a2%3Dr%2520b%26a3%3D2%2520q' + '%26a3%3Da%26b5%3D%253D%25253D%26c%2540%3D%26c2%3D%26oauth_consumer_' + 'key%3D9djdj82h48djs9d2%26oauth_nonce%3D7d8f3e4a%26oauth_signature_m' + 'ethod%3DHMAC-SHA1%26oauth_timestamp%3D137131201%26oauth_token%3Dkkk' + '9d7dh3k39sjv7'; + + final AuthorizationRequest req = AuthorizationRequest(); + req.add(AuthorizationRequest.oauth_consumer_key, '9djdj82h48djs9d2'); + req.add(AuthorizationRequest.oauth_signature_method, 'HMAC-SHA1'); + req.add(AuthorizationRequest.oauth_nonce, '7d8f3e4a'); + req.add(AuthorizationRequest.oauth_signature, + 'bYT5CMsGcbgUdFHObYMEfcx6bsw%3D'); + + req.add('c2', ''); + req.add('a3', '2 q'); // Note: URI's query parameters also has an "a3" + + const Credentials tokenCredentials = + Credentials('kkk9d7dh3k39sjv7', 'someSharedSecret'); + + const String method = 'POST'; + final Uri uri = Uri.parse( + 'http://example.com/request?b5=%3D%253D&a3=a&c%40=&a2=r%20b'); + + final ClientCredentials clientCredentials = + ClientCredentials('9djdj82h48djs9d2', 'someotherSecret'); + + const int exampleTimestamp = 137131201; + const String exampleNonce = '7d8f3e4a'; + + //---------------------------------------------------------------- + + test('string construction for creating signature', () { + final String signatureBaseString = req.sign( + method, uri, clientCredentials, SignatureMethods.hmacSha1, + tokenCredentials: tokenCredentials, + timestamp: exampleTimestamp, + nonce: exampleNonce); + + expect(signatureBaseString, equals(expectedSBS)); + }); + + //---------------------------------------------------------------- + + test('string construction for signature validation', () { + // The token_secret is not correct, so signature validation will fail. + // But this test only cares about whether the calculated signature base + // string has the expected value. + + try { + req.validate(clientCredentials, 'wrongSecret'); + fail('validated using the wrong secret'); + } on SignatureInvalid catch (e) { + // This will occur, since the wrong secret is used + expect(e.signatureBaseString, equals(expectedSBS)); + } + }); + }); + + //---------------------------------------------------------------- + + test('example from section 3.4.1.3.2 of RFC 5849', () { + final AuthorizationRequest req = AuthorizationRequest(); + + req.addAll(Uri.splitQueryString('c2&a3=2+q').map( + (String name, String value) => + MapEntry>(name, [value]))); + + final String signatureBaseString = req.sign( + 'POST', + Uri.parse( + 'http://example.com/request?b5=%3D%253D&a3=a&c%40=&a2=r%20b'), + ClientCredentials('9djdj82h48djs9d2', 'secret'), + SignatureMethods.hmacSha1, + tokenCredentials: + const Credentials('kkk9d7dh3k39sjv7', 'anotherSecret'), + timestamp: 137131201, + nonce: '7d8f3e4a'); + + // Check fully decoded parameters match those shown in section 3.4.1.3.1. + + expect(req.parameters['b5'].first, equals('=%3D')); + expect(req.parameters['a3'].contains('a'), isTrue); + expect(req.parameters['c@'].first, equals('')); + expect(req.parameters['a2'].first, equals('r b')); + expect(req.parameters['oauth_consumer_key'].first, + equals('9djdj82h48djs9d2')); + expect(req.parameters['oauth_token'].first, equals('kkk9d7dh3k39sjv7')); + expect( + req.parameters['oauth_signature_method'].first, equals('HMAC-SHA1')); + expect(req.parameters['oauth_timestamp'].first, equals('137131201')); + expect(req.parameters['oauth_nonce'].first, equals('7d8f3e4a')); + expect(req.parameters['c2'].first, equals('')); + expect(req.parameters['a3'].contains('2 q'), isTrue); + expect(req.parameters.length, equals(11)); + + const String expectedNormalizedParams = + 'a2=r%20b&a3=2%20q&a3=a&b5=%3D%253D&c%40=&c2=&oauth_consumer_key=9dj' + 'dj82h48djs9d2&oauth_nonce=7d8f3e4a&oauth_signature_method=HMAC-SHA1' + '&oauth_timestamp=137131201&oauth_token=kkk9d7dh3k39sjv7'; + + // Extract the normalized parameters part from the signature base string + + final parts = signatureBaseString.split('&'); + final String encoded = parts[2]; + final List bytes = []; + + int x = 0; + while (x < encoded.length) { + final int code = encoded.codeUnitAt(x); + if (code != 0x25) { + // not "%" + bytes.add(code); + x++; + } else { + // "%XX" + bytes.add(int.parse(encoded.substring(x + 1, x + 3), radix: 16)); + x += 3; + } + } + + final String decoded = String.fromCharCodes(bytes); + + expect(decoded, equals(expectedNormalizedParams)); + }); + + //---------------------------------------------------------------- + + test('encoding parameter values', () { + final AuthorizationRequest req = AuthorizationRequest(); + req.set('example-asterisk', '_*_'); + req.set('example-space', '_ _'); + req.set('example-reserved', '_ABCDabcd0123456789-._~'); + req.set('name-._~ _*_%_012345', 'names-are-encoded-too'); + + final String signatureBaseString = req.sign( + 'post', + Uri.parse('HTTP://EXAMPLE.COM:80'), + ClientCredentials('tester', 'secret'), + SignatureMethods.plaintext, + timestamp: 1, + nonce: 'abcdefgh'); + + expect( + signatureBaseString, + equals('POST&http%3A%2F%2Fexample.com&' + 'example-asterisk%3D_%252A_%26' + 'example-reserved%3D_ABCDabcd0123456789-._~%26' + 'example-space%3D_%2520_%26' + 'name-._~%2520_%252A_%2525_012345%3Dnames-are-encoded-too%26' + 'oauth_consumer_key%3Dtester%26' + 'oauth_nonce%3Dabcdefgh%26' + 'oauth_signature_method%3DPLAINTEXT%26' + 'oauth_timestamp%3D1')); + }); + }); + + //================================================================ + + group('sign, encode, parse and validate', () { + final Uri testUri = Uri.parse('HTTPS://Example.COM/Abc/Def'); + + const String clientId = 'client1'; + const String clientSharedSecret = 'secretValue'; + + final RSAKeyParser keyParser = RSAKeyParser(); + + final ClientCredentials clientCredentials = ClientCredentials( + clientId, clientSharedSecret, + privateKey: keyParser.parse(pemPrivateKey), + publicKey: keyParser.parse(pemPublicKey)); + + const String testTokenId = 'foo'; + const String testTokenSecret = 'bar'; + + const Credentials tokenCredentials = + Credentials(testTokenId, testTokenSecret); + + const int testTimestamp = 1; + const String testNonce = 'abcdef'; + + const String testRealm = 'https://realm.example.com'; + + //---------------------------------------------------------------- + + test('HMAC-SHA1 without token', () { + // Create signed OAuth request + + String authHeader; + { + final AuthorizationRequest req1 = AuthorizationRequest(); + req1.sign('post', testUri, clientCredentials, SignatureMethods.hmacSha1, + timestamp: testTimestamp, nonce: testNonce); + + // Check for expected parameters + + // print(req1); + + expect(req1.clientIdentifier, equals(clientId)); + expect(req1.timestamp, equals(testTimestamp.toString())); + expect(req1.nonce, equals(testNonce)); + expect(req1.signatureMethod, equals('HMAC-SHA1')); + expect(req1.signature, 'wEpcEetuMDVjzIPJBD2XXUrNdx0='); + expect(req1.version, isNull); + expect(req1.callback, isNull); + expect(req1.verifier, isNull); + expect(req1.token, isNull); // no tokenCredential passed to sign + + expect(req1.parameters.length, equals(5)); // all parameters + expect(req1.oauthParams().length, equals(5)); // OAuth parameters + + expect(req1.method, equals('POST')); // method converted to uppercase + // Domain is lowercase, but case in path is preserved + expect(req1.uri.toString(), equals('https://example.com/Abc/Def')); + + // Encode + + authHeader = req1.headerValue(realm: testRealm); + } + + expect(authHeader, + startsWith('OAuth realm="https%3A%2F%2Frealm.example.com",')); + + // Parse + + final AuthorizationRequest req2 = AuthorizationRequest.fromHttpRequest( + 'pOsT', // case insensitive + testUri, + [ + 'SomeScheme foo="bar"', // non-OAuth scheme headers will be ignored + authHeader, + 'OAuthX foo="bar"', // this is also a non-OAuth scheme header + ], + null); + + expect(req2.clientIdentifier, equals(clientId)); + expect(req2.timestamp, equals(testTimestamp.toString())); + expect(req2.nonce, equals(testNonce)); + expect(req2.signatureMethod, equals('HMAC-SHA1')); + expect(req2.signature, 'wEpcEetuMDVjzIPJBD2XXUrNdx0='); + expect(req2.version, isNull); + expect(req2.callback, isNull); + expect(req2.verifier, isNull); + expect(req2.token, isNull); // no tokenCredential passed to sign + + expect(req2.parameters.length, equals(5)); // all parameters + expect(req2.oauthParams().length, equals(5)); // OAuth parameters + + expect(req2.method, equals('POST')); + expect(req2.uri.toString(), equals('https://example.com/Abc/Def')); + + // Validate + + req2.validate(clientCredentials); + + final List realms = req2.realms; + expect(realms.length, equals(1)); // only one OAuth scheme header value + expect(realms[0], equals(testRealm)); + + //expect(signatureBaseString, equals(expectedSBS)); + }); + + //---------------------------------------------------------------- + + test('PLAINTEXT with token', () { + // Create signed OAuth request + + String signatureBaseStringSigned; + String authHeader; + { + final AuthorizationRequest req1 = AuthorizationRequest(); + + // Set the optional oauth_version + req1.set(AuthorizationRequest.oauth_version, + AuthorizationRequest.supportedVersion); + + signatureBaseStringSigned = req1.sign( + 'PosT', testUri, clientCredentials, SignatureMethods.plaintext, + tokenCredentials: tokenCredentials, + timestamp: testTimestamp, + nonce: testNonce); + + // Check for expected parameters + + // print(req1); + + expect(req1.clientIdentifier, equals(clientId)); + expect(req1.timestamp, equals(testTimestamp.toString())); + expect(req1.nonce, equals(testNonce)); + expect(req1.signatureMethod, equals('PLAINTEXT')); + expect(req1.signature, '$clientSharedSecret&$testTokenSecret'); + expect(req1.version, '1.0'); + expect(req1.callback, isNull); + expect(req1.verifier, isNull); + expect(req1.token, testTokenId); + + expect(req1.parameters.length, equals(7)); // all parameters + expect(req1.oauthParams().length, equals(7)); // OAuth parameters + + expect(req1.method, equals('POST')); // method converted to uppercase + // Domain is lowercase, but case in path is preserved + expect(req1.uri.toString(), equals('https://example.com/Abc/Def')); + + // Encode + + authHeader = req1.headerValue(realm: testRealm); + } + + expect(authHeader, + startsWith('OAuth realm="https%3A%2F%2Frealm.example.com",')); + + // Parse + + final AuthorizationRequest req2 = AuthorizationRequest.fromHttpRequest( + 'post', // case insensitive + testUri, + [ + 'SomeScheme foo="bar"', // non-OAuth scheme headers will be ignored + authHeader, + 'OAuthX foo="bar"', // this is also a non-OAuth scheme header + ], + null); + + expect(req2.clientIdentifier, equals(clientId)); + expect(req2.timestamp, equals(testTimestamp.toString())); + expect(req2.nonce, equals(testNonce)); + expect(req2.signatureMethod, equals('PLAINTEXT')); + expect(req2.signature, '$clientSharedSecret&$testTokenSecret'); + expect(req2.version, AuthorizationRequest.supportedVersion); + expect(req2.callback, isNull); + expect(req2.verifier, isNull); + expect(req2.token, testTokenId); + + expect(req2.parameters.length, equals(7)); // all parameters + expect(req2.oauthParams().length, equals(7)); // OAuth parameters + + expect(req2.method, equals('POST')); + expect(req2.uri.toString(), equals('https://example.com/Abc/Def')); + + // Validate + + try { + req2.validate(clientCredentials, tokenCredentials.tokenSecret); + } on SignatureInvalid catch (e) { + // print(signatureBaseStringSigned); + // print(e.signatureBaseString); + expect(e.signatureBaseString, equals(signatureBaseStringSigned), + reason: 'invalid because signature base strings are not the same'); + fail('invalid for some other reason'); + } + + final List realms = req2.realms; + expect(realms.length, equals(1)); // only one OAuth scheme header value + expect(realms[0], equals(testRealm)); + + //expect(signatureBaseString, equals(expectedSBS)); + }); + + //---------------------------------------------------------------- + + test('RSA-SHA1 with token and other parameters', () { + // Create signed OAuth request + + String signatureBaseStringSigned; + String authHeader; + { + final AuthorizationRequest req1 = AuthorizationRequest(); + + req1.set('a', 'b'); + req1.add('foo', 'bar'); + req1.add('foo', 'baz'); + req1.addAll(>{ + 'alphabet': ['alpha', 'beta', 'gamma'] + }); + + signatureBaseStringSigned = req1.sign( + 'GET', testUri, clientCredentials, SignatureMethods.rsaSha1, + tokenCredentials: tokenCredentials, + timestamp: testTimestamp, + nonce: testNonce); + + // Check for expected parameters + + // print(req1); + + expect(req1.clientIdentifier, equals(clientId)); + expect(req1.timestamp, equals(testTimestamp.toString())); + expect(req1.nonce, equals(testNonce)); + expect(req1.signatureMethod, equals('RSA-SHA1')); + expect( + req1.signature, + 'jy6wd1niBBz6TrkcbppUhc3txZZJb1wYL5XM09+6GzRZCl3EW8OST9PWnWAJ34pRWq' + '6rYaRSo9Ig55NEWJ1l72Sc/Ck8a54HuLRbjEBLCSBOdy0x8lcCa1EHa8nc5pbtmoaV' + '9LztE6Y35PQNxnNa6azZ7J1dwC8ZWejG404XlfGPveC4JncUooPh7YZ+h69kH3kOrD' + 'zK7xA/DoIMsQrE41jKmmWo2MlzpfKpGJjR1wkbPej72tLxk2jSHzHd0a8L6HmY6ZoG' + 'TYlZ9o0WMB2hbAn25sCOAgnFcy7wvBaUydJCVJfVjdAN7U0OOkThIPpIelX8BVzWwE' + 'BpEErxwaCanxd1Q/qKjFAkfi6IcYEMy+1AXOvTF18iML2G+8tLZEb05I211JQH6qna' + 'DQQUdAa0vxeBVrxDuWhCA9U36cPs04DWlhUYfWY7U+y6uNTzamheU002EgoRrRPsCH' + 'tWC7ksM0nKsH5uIYcp6GrRd13W44GfTTaFTITlr78SZebIavhVz+gvYPVqGT2RRsi9' + 'goifRN91eXItvPADr3f8HB8lbEd4kKq282olI46tv4dwnhY/91K/evMAQyuCdjKnEc' + '24Ps13uO7aXUCwZhn2nL/lEVSvMxRvM9l01gU48dUNKFO74YAbGCJLadprLISnC9tQ' + 'J3qIfItQeLnhFnm29fuK6Og='); + expect(req1.version, isNull); + expect(req1.callback, isNull); + expect(req1.verifier, isNull); + expect(req1.token, testTokenId); + + expect(req1.parameters.length, equals(9)); // all parameters + expect(req1.oauthParams().length, equals(6)); // OAuth parameters + + expect(req1.method, equals('GET')); + // Domain is lowercase, but case in path is preserved + expect(req1.uri.toString(), equals('https://example.com/Abc/Def')); + + expect(req1.get('a').length, equals(1)); + expect(req1.get('foo').length, equals(2)); + expect(req1.get('alphabet').length, equals(3)); + + // Encode + + authHeader = req1.headerValue(realm: testRealm); + } + + expect(authHeader, + startsWith('OAuth realm="https%3A%2F%2Frealm.example.com",')); + + // Parse + // + // Note: non-OAuth parameters are assumed to have been transmitted as + // query parameters and in the url-encoded body. + + final AuthorizationRequest req2 = AuthorizationRequest.fromHttpRequest( + 'get', // case insensitive + Uri( + scheme: testUri.scheme, + host: testUri.host, + path: testUri.path, + query: 'alphabet=gamma&foo=baz'), // testUri with extra parameters + [authHeader], + 'a=b&foo=bar&alphabet=alpha&alphabet=beta'); + + expect(req2.clientIdentifier, equals(clientId)); + expect(req2.timestamp, equals(testTimestamp.toString())); + expect(req2.nonce, equals(testNonce)); + expect(req2.signatureMethod, equals('RSA-SHA1')); + expect(base64.decode(req2.signature).length, equals(512)); + expect(req2.version, isNull); + expect(req2.callback, isNull); + expect(req2.verifier, isNull); + expect(req2.token, testTokenId); + + expect(req2.parameters.length, equals(9)); // all parameters + expect(req2.oauthParams().length, equals(6)); // OAuth parameters + + expect(req2.method, equals('GET')); + expect(req2.uri.toString(), equals('https://example.com/Abc/Def')); + + expect(req2.get('a').length, equals(1)); + expect(req2.get('foo').length, equals(2)); + expect(req2.get('alphabet').length, equals(3)); + + // Validate + + req2.validate(clientCredentials); + + final List realms = req2.realms; + expect(realms.length, equals(1)); // only one OAuth scheme header value + expect(realms[0], equals(testRealm)); + + // Tampering with any of the parameters will invalidate the signature + + req2.set('a', 'B'); // changed value from 'b' to 'B'. + try { + req2.validate(clientCredentials); + fail('tampered value still validates'); + } on SignatureInvalid catch (e) { + // Invalid because the signature base string is now different + expect(e.signatureBaseString, isNot(equals(signatureBaseStringSigned))); + } + }); + }); +} + +//################################################################ +// RSA public and private keys + +const String pemPublicKey = ''' +-----BEGIN RSA PUBLIC KEY----- +MIICCgKCAgEAsBYJRkO/c6eMgUosXpHXCBH5uE3+gR04IvkNzz5z9phaMxHUITSG +9qdJ7+sGgGnIl4Zd+NnwtfP+cUZaP46ySh+OHPFNt+MnwAd1hveJeG+9cB9Nd3je +ytdQHtqoE47kai7kNuLFEVHst0+wa3+aoJnrFckii5SK6g2tWiP9Z9IyiCLS7//U +GQQD3Q1zxqsTQCWKpQkcVKzkiq198pl2gI6qDsSO6cusg6tLqcf243C4/RkGf1EL +ug6AHte1T1ip0Czoj6VkmeiMUqSBvNmJOHLAuqaaltC+6Q07PC+Lm8/m1RJnQkmF +VOY1DDc/TSWwYO/DCsoarM3LjxFDTOSnhE4qZXn0f2hV48syqbavW0IKmCH+JHWW +oZgVm0ZDB3hMwlY2UaAnranw/EOONnAim2ebZoKbeaBX5KhtY1CNF6cMdNDx0D/B +4zZHcza3/BgN35PiVDj8teDX3bjwL2+sCkbaH9BKadal3VBw2RK7hPgMq26i57iY +AFDaXX9poFVZYrzHkVf2ja58TRF2fOZ85AV2uVoY0E3AN6GIPJQu16/SD6MPhneY +NiuqbV+RBsficySkdwRdcS8O+/FP928G67lEK2/akdhp0yhLlDQNlr2froIbBlaQ +xQVq0xyuiGr068ndvtFTiVVQh/JwC8bXMmh8IgI5A5XZb5AX0RpFcScCAwEAAQ== +-----END RSA PUBLIC KEY----- + '''; + +const String pemPrivateKey = ''' +-----BEGIN RSA PRIVATE KEY----- +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 +-----END RSA PRIVATE KEY----- +''';