diff --git a/.github/workflows/oauth2.yml b/.github/workflows/oauth2.yml new file mode 100644 index 000000000..820dd1a4c --- /dev/null +++ b/.github/workflows/oauth2.yml @@ -0,0 +1,79 @@ +name: Dart CI + +on: + # Run on PRs and pushes to the default branch. + push: + branches: [ main ] + paths: + - '.github/workflows/oauth2.yml' + - 'pkgs/oauth2/**' + pull_request: + branches: [ main ] + paths: + - '.github/workflows/oauth2.yml' + - 'pkgs/oauth2/**' + schedule: + - cron: "0 0 * * 0" + +env: + PUB_ENVIRONMENT: bot.github + +jobs: + # Check code formatting and lints against Dart dev, check analyzer warnings + # against the oldest supported SDK. + analyze: + runs-on: ubuntu-latest + defaults: + run: + working-directory: pkgs/oauth2/ + strategy: + fail-fast: false + matrix: + sdk: [3.0.0, dev] + steps: + - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 + - uses: dart-lang/setup-dart@0a8a0fc875eb934c15d08629302413c671d3f672 + with: + sdk: ${{ matrix.sdk }} + - id: install + name: Install dependencies + run: dart pub get + - name: Check formatting + run: dart format --output=none --set-exit-if-changed . + if: matrix.sdk == 'dev' && steps.install.outcome == 'success' + - name: Analyze code + run: dart analyze --fatal-infos + if: matrix.sdk == 'dev' && steps.install.outcome == 'success' + - name: Analyze code + run: dart analyze + if: matrix.sdk != 'dev' && steps.install.outcome == 'success' + + # Run tests on a matrix consisting of two dimensions: + # 1. OS: ubuntu-latest, (macos-latest, windows-latest) + # 2. release channel: dev + test: + needs: analyze + runs-on: ${{ matrix.os }} + defaults: + run: + working-directory: pkgs/oauth2/ + strategy: + fail-fast: false + matrix: + # Add macos-latest and/or windows-latest if relevant for this package. + os: [ubuntu-latest] + sdk: [stable, beta] + steps: + - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 + - uses: dart-lang/setup-dart@0a8a0fc875eb934c15d08629302413c671d3f672 + with: + sdk: ${{ matrix.sdk }} + - id: install + name: Install dependencies + run: dart pub get + - name: Run VM tests + run: dart test --platform vm + if: always() && steps.install.outcome == 'success' + - name: Run Chrome tests + run: dart test --platform chrome + if: always() && steps.install.outcome == 'success' diff --git a/README.md b/README.md index daf3d7195..5847cf74b 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ don't naturally belong to other topic monorepos (like | [graphs](pkgs/graphs/) | Graph algorithms that operate on graphs in any representation | [![pub package](https://img.shields.io/pub/v/graphs.svg)](https://pub.dev/packages/graphs) | | [unified_analytics](pkgs/unified_analytics/) | A package for logging analytics for all Dart and Flutter related tooling to Google Analytics. | [![pub package](https://img.shields.io/pub/v/unified_analytics.svg)](https://pub.dev/packages/unified_analytics) | | [source_map_stack_trace](pkgs/source_map_stack_trace/) | A package for applying source maps to stack traces. | [![pub package](https://img.shields.io/pub/v/source_map_stack_trace.svg)](https://pub.dev/packages/source_map_stack_trace) | +| [oauth2](pkgs/oauth2/) | A client library for authenticatingand making requests via OAuth2. | [![pub package](https://img.shields.io/pub/v/oauth2.svg)](https://pub.dev/packages/oauth2) | ## Publishing automation diff --git a/pkgs/oauth2/.gitignore b/pkgs/oauth2/.gitignore new file mode 100644 index 000000000..bbe1007d7 --- /dev/null +++ b/pkgs/oauth2/.gitignore @@ -0,0 +1,16 @@ +# Don’t commit the following directories created by pub. +.buildlog +.pub/ +.dart_tool/ +build/ +packages +.packages + +# Or the files created by dart2js. +*.dart.js +*.js_ +*.js.deps +*.js.map + +# Include when developing application packages. +pubspec.lock \ No newline at end of file diff --git a/pkgs/oauth2/CHANGELOG.md b/pkgs/oauth2/CHANGELOG.md new file mode 100644 index 000000000..33d927e25 --- /dev/null +++ b/pkgs/oauth2/CHANGELOG.md @@ -0,0 +1,131 @@ +## 2.0.3 + +* Require `package:http` v1.0.0 +* Move to `dart-lang/tools`. + +## 2.0.2 + +* Require Dart 3.0. +* Support `package:http` 1.0.0. + +## 2.0.1 + +* Handle `expires_in` when encoded as string. +* Populate the pubspec `repository` field. +* Increase the minimum Dart SDK to `2.17.0`. + +## 2.0.0 + +* Migrate to null safety. + +## 1.6.3 + +* Added optional `codeVerifier` parameter to `AuthorizationCodeGrant` constructor. + +## 1.6.1 + +* Added fix to make sure that credentials are only refreshed once when multiple calls are made. + +## 1.6.0 + +* Added PKCE support to `AuthorizationCodeGrant`. + +## 1.5.0 + +* Added support for `clientCredentialsGrant`. + +## 1.4.0 + +* OpenID's id_token treated. + +## 1.3.0 + +* Added `onCredentialsRefreshed` option when creating `Client` objects. + +## 1.2.3 + +* Support the latest `package:http` release. + +## 1.2.2 + +* Allow the stable 2.0 SDK. + +## 1.2.1 + +* Updated SDK version to 2.0.0-dev.17.0 + +## 1.2.0 + +* Add a `getParameter()` parameter to `new AuthorizationCodeGrant()`, `new + Credentials()`, and `resourceOwnerPasswordGrant()`. This controls how the + authorization server's response is parsed for servers that don't provide the + standard JSON response. + +## 1.1.1 + +* `resourceOwnerPasswordGrant()` now properly uses its HTTP client for requests + made by the OAuth2 client it returns. + +## 1.1.0 + +* Add a `delimiter` parameter to `new AuthorizationCodeGrant()`, `new + Credentials()`, and `resourceOwnerPasswordGrant()`. This controls the + delimiter between scopes, which some authorization servers require to be + different values than the specified `' '`. + +## 1.0.2 + +* Fix all strong-mode warnings. +* Support `crypto` 1.0.0. +* Support `http_parser` 3.0.0. + +## 1.0.1 + +* Support `http_parser` 2.0.0. + +## 1.0.0 + +### Breaking changes + +* Requests that use client authentication, such as the + `AuthorizationCodeGrant`'s access token request and `Credentials`' refresh + request, now use HTTP Basic authentication by default. This form of + authentication is strongly recommended by the OAuth 2.0 spec. The new + `basicAuth` parameter may be set to `false` to force form-based authentication + for servers that require it. + +* `new AuthorizationCodeGrant()` now takes `secret` as an optional named + argument rather than a required argument. This matches the OAuth 2.0 spec, + which says that a client secret is only required for confidential clients. + +* `new Client()` and `Credentials.refresh()` now take both `identifier` and + `secret` as optional named arguments rather than required arguments. This + matches the OAuth 2.0 spec, which says that the server may choose not to + require client authentication for some flows. + +* `new Credentials()` now takes named arguments rather than optional positional + arguments. + +### Non-breaking changes + +* Added a `resourceOwnerPasswordGrant` method. + +* The `scopes` argument to `AuthorizationCodeGrant.getAuthorizationUrl()` and + `new Credentials()` and the `newScopes` argument to `Credentials.refresh` now + take an `Iterable` rather than just a `List`. + +* The `scopes` argument to `AuthorizationCodeGrant.getAuthorizationUrl()` now + defaults to `null` rather than `const []`. + +# 0.9.3 + +* Update the `http` dependency. + +* Since `http` 0.11.0 now works in non-`dart:io` contexts, `oauth2` does as + well. + +## 0.9.2 + +* Expand the dependency on the HTTP package to include 0.10.x. + +* Add a README file. diff --git a/pkgs/oauth2/LICENSE b/pkgs/oauth2/LICENSE new file mode 100644 index 000000000..162572a44 --- /dev/null +++ b/pkgs/oauth2/LICENSE @@ -0,0 +1,27 @@ +Copyright 2014, the Dart project authors. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + * Neither the name of Google LLC nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/pkgs/oauth2/README.md b/pkgs/oauth2/README.md new file mode 100644 index 000000000..07a5976b6 --- /dev/null +++ b/pkgs/oauth2/README.md @@ -0,0 +1,260 @@ +[![Dart CI](https://github.com/dart-lang/tools/actions/workflows/oauth2.yml/badge.svg)](https://github.com/dart-lang/tools/actions/workflows/oauth2.yml) +[![pub package](https://img.shields.io/pub/v/oauth2.svg)](https://pub.dev/packages/oauth2) +[![package publisher](https://img.shields.io/pub/publisher/oauth2.svg)](https://pub.dev/packages/oauth2/publisher) + +A client library for authenticating with a remote service via OAuth2 on behalf +of a user, and making authorized HTTP requests with the user's OAuth2 +credentials. + +## About OAuth2 + +OAuth2 allows a client (the program using this library) to access and manipulate +a resource that's owned by a resource owner (the end user) and lives on a remote +server. The client directs the resource owner to an authorization server +(usually but not always the same as the server that hosts the resource), where +the resource owner tells the authorization server to give the client an access +token. This token serves as proof that the client has permission to access +resources on behalf of the resource owner. + +OAuth2 provides several different methods for the client to obtain +authorization. At the time of writing, this library only supports the +[Authorization Code Grant][authorizationCodeGrantSection], +[Client Credentials Grant][clientCredentialsGrantSection] and +[Resource Owner Password Grant][resourceOwnerPasswordGrantSection] flows, but +more may be added in the future. + +## Authorization Code Grant + +**Resources:** [Class summary][authorizationCodeGrantMethod], +[OAuth documentation][authorizationCodeGrantDocs] + +```dart +import 'dart:io'; + +import 'package:oauth2/oauth2.dart' as oauth2; + +// These URLs are endpoints that are provided by the authorization +// server. They're usually included in the server's documentation of its +// OAuth2 API. +final authorizationEndpoint = + Uri.parse('http://example.com/oauth2/authorization'); +final tokenEndpoint = Uri.parse('http://example.com/oauth2/token'); + +// The authorization server will issue each client a separate client +// identifier and secret, which allows the server to tell which client +// is accessing it. Some servers may also have an anonymous +// identifier/secret pair that any client may use. +// +// Note that clients whose source code or binary executable is readily +// available may not be able to make sure the client secret is kept a +// secret. This is fine; OAuth2 servers generally won't rely on knowing +// with certainty that a client is who it claims to be. +final identifier = 'my client identifier'; +final secret = 'my client secret'; + +// This is a URL on your application's server. The authorization server +// will redirect the resource owner here once they've authorized the +// client. The redirection will include the authorization code in the +// query parameters. +final redirectUrl = Uri.parse('http://my-site.com/oauth2-redirect'); + +/// A file in which the users credentials are stored persistently. If the server +/// issues a refresh token allowing the client to refresh outdated credentials, +/// these may be valid indefinitely, meaning the user never has to +/// re-authenticate. +final credentialsFile = File('~/.myapp/credentials.json'); + +/// Either load an OAuth2 client from saved credentials or authenticate a new +/// one. +Future createClient() async { + var exists = await credentialsFile.exists(); + + // If the OAuth2 credentials have already been saved from a previous run, we + // just want to reload them. + if (exists) { + var credentials = + oauth2.Credentials.fromJson(await credentialsFile.readAsString()); + return oauth2.Client(credentials, identifier: identifier, secret: secret); + } + + // If we don't have OAuth2 credentials yet, we need to get the resource owner + // to authorize us. We're assuming here that we're a command-line application. + var grant = oauth2.AuthorizationCodeGrant( + identifier, authorizationEndpoint, tokenEndpoint, + secret: secret); + + // A URL on the authorization server (authorizationEndpoint with some additional + // query parameters). Scopes and state can optionally be passed into this method. + var authorizationUrl = grant.getAuthorizationUrl(redirectUrl); + + // Redirect the resource owner to the authorization URL. Once the resource + // owner has authorized, they'll be redirected to `redirectUrl` with an + // authorization code. The `redirect` should cause the browser to redirect to + // another URL which should also have a listener. + // + // `redirect` and `listen` are not shown implemented here. See below for the + // details. + await redirect(authorizationUrl); + var responseUrl = await listen(redirectUrl); + + // Once the user is redirected to `redirectUrl`, pass the query parameters to + // the AuthorizationCodeGrant. It will validate them and extract the + // authorization code to create a new Client. + return await grant.handleAuthorizationResponse(responseUrl.queryParameters); +} + +void main() async { + var client = await createClient(); + + // Once you have a Client, you can use it just like any other HTTP client. + print(await client.read('http://example.com/protected-resources.txt')); + + // Once we're done with the client, save the credentials file. This ensures + // that if the credentials were automatically refreshed while using the + // client, the new credentials are available for the next run of the + // program. + await credentialsFile.writeAsString(client.credentials.toJson()); +} +``` + +
+ Click here to learn how to implement `redirect` and `listen`. + +-------------------------------------------------------------------------------- + +There is not a universal example for implementing `redirect` and `listen`, +because different options exist for each platform. + +For Flutter apps, there's two popular approaches: + +1. Launch a browser using [url_launcher][] and listen for a redirect using + [uni_links][]. + + ```dart + if (await canLaunch(authorizationUrl.toString())) { + await launch(authorizationUrl.toString()); } + + // ------- 8< ------- + + final linksStream = getLinksStream().listen((Uri uri) async { + if (uri.toString().startsWith(redirectUrl)) { + responseUrl = uri; + } + }); + ``` + +1. Launch a WebView inside the app and listen for a redirect using + [webview_flutter][]. + + ```dart + WebView( + javascriptMode: JavascriptMode.unrestricted, + initialUrl: authorizationUrl.toString(), + navigationDelegate: (navReq) { + if (navReq.url.startsWith(redirectUrl)) { + responseUrl = Uri.parse(navReq.url); + return NavigationDecision.prevent; + } + return NavigationDecision.navigate; + }, + // ------- 8< ------- + ); + ``` + +For Dart apps, the best approach depends on the available options for accessing +a browser. In general, you'll need to launch the authorization URL through the +client's browser and listen for the redirect URL. +
+ +## Client Credentials Grant + +**Resources:** [Method summary][clientCredentialsGrantMethod], +[OAuth documentation][clientCredentialsGrantDocs] + +```dart +// This URL is an endpoint that's provided by the authorization server. It's +// usually included in the server's documentation of its OAuth2 API. +final authorizationEndpoint = + Uri.parse('http://example.com/oauth2/authorization'); + +// The OAuth2 specification expects a client's identifier and secret +// to be sent when using the client credentials grant. +// +// Because the client credentials grant is not inherently associated with a user, +// it is up to the server in question whether the returned token allows limited +// API access. +// +// Either way, you must provide both a client identifier and a client secret: +final identifier = 'my client identifier'; +final secret = 'my client secret'; + +// Calling the top-level `clientCredentialsGrant` function will return a +// [Client] instead. +var client = await oauth2.clientCredentialsGrant( + authorizationEndpoint, identifier, secret); + +// With an authenticated client, you can make requests, and the `Bearer` token +// returned by the server during the client credentials grant will be attached +// to any request you make. +var response = + await client.read('https://example.com/api/some_resource.json'); + +// You can save the client's credentials, which consists of an access token, and +// potentially a refresh token and expiry date, to a file. This way, subsequent runs +// do not need to reauthenticate, and you can avoid saving the client identifier and +// secret. +await credentialsFile.writeAsString(client.credentials.toJson()); +``` + +## Resource Owner Password Grant + +**Resources:** [Method summary][resourceOwnerPasswordGrantMethod], +[OAuth documentation][resourceOwnerPasswordGrantDocs] + +```dart +// This URL is an endpoint that's provided by the authorization server. It's +// usually included in the server's documentation of its OAuth2 API. +final authorizationEndpoint = + Uri.parse('http://example.com/oauth2/authorization'); + +// The user should supply their own username and password. +final username = 'example user'; +final password = 'example password'; + +// The authorization server may issue each client a separate client +// identifier and secret, which allows the server to tell which client +// is accessing it. Some servers may also have an anonymous +// identifier/secret pair that any client may use. +// +// Some servers don't require the client to authenticate itself, in which case +// these should be omitted. +final identifier = 'my client identifier'; +final secret = 'my client secret'; + +// Make a request to the authorization endpoint that will produce the fully +// authenticated Client. +var client = await oauth2.resourceOwnerPasswordGrant( + authorizationEndpoint, username, password, + identifier: identifier, secret: secret); + +// Once you have the client, you can use it just like any other HTTP client. +var result = await client.read('http://example.com/protected-resources.txt'); + +// Once we're done with the client, save the credentials file. This will allow +// us to re-use the credentials and avoid storing the username and password +// directly. +File('~/.myapp/credentials.json').writeAsString(client.credentials.toJson()); +``` + +[authorizationCodeGrantDocs]: https://oauth.net/2/grant-types/authorization-code/ +[authorizationCodeGrantMethod]: https://pub.dev/documentation/oauth2/latest/oauth2/AuthorizationCodeGrant-class.html +[authorizationCodeGrantSection]: #authorization-code-grant +[clientCredentialsGrantDocs]: https://oauth.net/2/grant-types/client-credentials/ +[clientCredentialsGrantMethod]: https://pub.dev/documentation/oauth2/latest/oauth2/clientCredentialsGrant.html +[clientCredentialsGrantSection]: #client-credentials-grant +[resourceOwnerPasswordGrantDocs]: https://oauth.net/2/grant-types/password/ +[resourceOwnerPasswordGrantMethod]: https://pub.dev/documentation/oauth2/latest/oauth2/resourceOwnerPasswordGrant.html +[resourceOwnerPasswordGrantSection]: #resource-owner-password-grant +[uni_links]: https://pub.dev/packages/uni_links +[url_launcher]: https://pub.dev/packages/url_launcher +[webview_flutter]: https://pub.dev/packages/webview_flutter diff --git a/pkgs/oauth2/analysis_options.yaml b/pkgs/oauth2/analysis_options.yaml new file mode 100644 index 000000000..68d2a49af --- /dev/null +++ b/pkgs/oauth2/analysis_options.yaml @@ -0,0 +1,43 @@ +include: package:dart_flutter_team_lints/analysis_options.yaml + +analyzer: + errors: + # Too many exceptions + comment_references: ignore + language: + strict-casts: true + strict-raw-types: true + +linter: + rules: + - always_declare_return_types + - avoid_catching_errors + - avoid_dynamic_calls + - avoid_private_typedef_functions + - avoid_unused_constructor_parameters + - avoid_void_async + - cancel_subscriptions + - directives_ordering + - literal_only_boolean_expressions + - no_adjacent_strings_in_list + - no_runtimeType_toString + - omit_local_variable_types + - only_throw_errors + - package_api_docs + - prefer_asserts_in_initializer_lists + - prefer_const_constructors + - prefer_const_declarations + - prefer_relative_imports + - prefer_single_quotes + - sort_pub_dependencies + - test_types_in_equals + - throw_in_finally + - type_annotate_public_apis + - unawaited_futures + - unnecessary_await_in_return + - unnecessary_lambdas + - unnecessary_parenthesis + - unnecessary_statements + - use_is_even_rather_than_modulo + - use_string_buffers + - use_super_parameters diff --git a/pkgs/oauth2/example/main.dart b/pkgs/oauth2/example/main.dart new file mode 100644 index 000000000..68e5aa062 --- /dev/null +++ b/pkgs/oauth2/example/main.dart @@ -0,0 +1,100 @@ +// Copyright (c) 2024, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'dart:async'; +import 'dart:io'; + +import 'package:oauth2/oauth2.dart' as oauth2; + +// These URLs are endpoints that are provided by the authorization +// server. They're usually included in the server's documentation of its +// OAuth2 API. +final authorizationEndpoint = + Uri.parse('http://example.com/oauth2/authorization'); +final tokenEndpoint = Uri.parse('http://example.com/oauth2/token'); + +// The authorization server will issue each client a separate client +// identifier and secret, which allows the server to tell which client +// is accessing it. Some servers may also have an anonymous +// identifier/secret pair that any client may use. +// +// Note that clients whose source code or binary executable is readily +// available may not be able to make sure the client secret is kept a +// secret. This is fine; OAuth2 servers generally won't rely on knowing +// with certainty that a client is who it claims to be. +const identifier = 'my client identifier'; +const secret = 'my client secret'; + +// This is a URL on your application's server. The authorization server +// will redirect the resource owner here once they've authorized the +// client. The redirection will include the authorization code in the +// query parameters. +final redirectUrl = Uri.parse('http://my-site.com/oauth2-redirect'); + +/// A file in which the users credentials are stored persistently. If the server +/// issues a refresh token allowing the client to refresh outdated credentials, +/// these may be valid indefinitely, meaning the user never has to +/// re-authenticate. +final credentialsFile = File('~/.myapp/credentials.json'); + +/// Either load an OAuth2 client from saved credentials or authenticate a new +/// one. +Future createClient() async { + var exists = await credentialsFile.exists(); + + // If the OAuth2 credentials have already been saved from a previous run, we + // just want to reload them. + if (exists) { + var credentials = + oauth2.Credentials.fromJson(await credentialsFile.readAsString()); + return oauth2.Client(credentials, identifier: identifier, secret: secret); + } + + // If we don't have OAuth2 credentials yet, we need to get the resource owner + // to authorize us. We're assuming here that we're a command-line application. + var grant = oauth2.AuthorizationCodeGrant( + identifier, authorizationEndpoint, tokenEndpoint, + secret: secret); + + // A URL on the authorization server (authorizationEndpoint with some + // additional query parameters). Scopes and state can optionally be passed + // into this method. + var authorizationUrl = grant.getAuthorizationUrl(redirectUrl); + + // Redirect the resource owner to the authorization URL. Once the resource + // owner has authorized, they'll be redirected to `redirectUrl` with an + // authorization code. The `redirect` should cause the browser to redirect to + // another URL which should also have a listener. + // + // `redirect` and `listen` are not shown implemented here. + await redirect(authorizationUrl); + var responseUrl = await listen(redirectUrl); + + // Once the user is redirected to `redirectUrl`, pass the query parameters to + // the AuthorizationCodeGrant. It will validate them and extract the + // authorization code to create a new Client. + return grant.handleAuthorizationResponse(responseUrl.queryParameters); +} + +void main() async { + var client = await createClient(); + + // Once you have a Client, you can use it just like any other HTTP client. + print(await client.read(Uri.http('example.com', 'protected-resources.txt'))); + + // Once we're done with the client, save the credentials file. This ensures + // that if the credentials were automatically refreshed while using the + // client, the new credentials are available for the next run of the + // program. + await credentialsFile.writeAsString(client.credentials.toJson()); +} + +Future redirect(Uri url) async { + // Client implementation detail +} + +Future listen(Uri url) async { + // Client implementation detail + return Uri(); +} diff --git a/pkgs/oauth2/lib/oauth2.dart b/pkgs/oauth2/lib/oauth2.dart new file mode 100644 index 000000000..45efc5c1b --- /dev/null +++ b/pkgs/oauth2/lib/oauth2.dart @@ -0,0 +1,11 @@ +// Copyright (c) 2012, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +export 'src/authorization_code_grant.dart'; +export 'src/authorization_exception.dart'; +export 'src/client.dart'; +export 'src/client_credentials_grant.dart'; +export 'src/credentials.dart'; +export 'src/expiration_exception.dart'; +export 'src/resource_owner_password_grant.dart'; diff --git a/pkgs/oauth2/lib/src/authorization_code_grant.dart b/pkgs/oauth2/lib/src/authorization_code_grant.dart new file mode 100644 index 000000000..fac56ba0d --- /dev/null +++ b/pkgs/oauth2/lib/src/authorization_code_grant.dart @@ -0,0 +1,371 @@ +// Copyright (c) 2012, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'dart:async'; +import 'dart:convert'; +import 'dart:math'; + +import 'package:crypto/crypto.dart'; +import 'package:http/http.dart' as http; +import 'package:http_parser/http_parser.dart'; + +import 'authorization_exception.dart'; +import 'client.dart'; +import 'credentials.dart'; +import 'handle_access_token_response.dart'; +import 'parameters.dart'; +import 'utils.dart'; + +/// A class for obtaining credentials via an [authorization code grant][]. +/// +/// This method of authorization involves sending the resource owner to the +/// authorization server where they will authorize the client. They're then +/// redirected back to your server, along with an authorization code. This is +/// used to obtain [Credentials] and create a fully-authorized [Client]. +/// +/// To use this class, you must first call [getAuthorizationUrl] to get the URL +/// to which to redirect the resource owner. Then once they've been redirected +/// back to your application, call [handleAuthorizationResponse] or +/// [handleAuthorizationCode] to process the authorization server's response and +/// construct a [Client]. +/// +/// [authorization code grant]: http://tools.ietf.org/html/draft-ietf-oauth-v2-31#section-4.1 +class AuthorizationCodeGrant { + /// The function used to parse parameters from a host's response. + final GetParameters _getParameters; + + /// The client identifier for this client. + /// + /// The authorization server will issue each client a separate client + /// identifier and secret, which allows the server to tell which client is + /// accessing it. Some servers may also have an anonymous identifier/secret + /// pair that any client may use. + /// + /// This is usually global to the program using this library. + final String identifier; + + /// The client secret for this client. + /// + /// The authorization server will issue each client a separate client + /// identifier and secret, which allows the server to tell which client is + /// accessing it. Some servers may also have an anonymous identifier/secret + /// pair that any client may use. + /// + /// This is usually global to the program using this library. + /// + /// Note that clients whose source code or binary executable is readily + /// available may not be able to make sure the client secret is kept a secret. + /// This is fine; OAuth2 servers generally won't rely on knowing with + /// certainty that a client is who it claims to be. + final String? secret; + + /// A URL provided by the authorization server that serves as the base for the + /// URL that the resource owner will be redirected to to authorize this + /// client. + /// + /// This will usually be listed in the authorization server's OAuth2 API + /// documentation. + final Uri authorizationEndpoint; + + /// A URL provided by the authorization server that this library uses to + /// obtain long-lasting credentials. + /// + /// This will usually be listed in the authorization server's OAuth2 API + /// documentation. + final Uri tokenEndpoint; + + /// Callback to be invoked whenever the credentials are refreshed. + /// + /// This will be passed as-is to the constructed [Client]. + final CredentialsRefreshedCallback? _onCredentialsRefreshed; + + /// Whether to use HTTP Basic authentication for authorizing the client. + final bool _basicAuth; + + /// A [String] used to separate scopes; defaults to `" "`. + final String _delimiter; + + /// The HTTP client used to make HTTP requests. + http.Client? _httpClient; + + /// The URL to which the resource owner will be redirected after they + /// authorize this client with the authorization server. + Uri? _redirectEndpoint; + + /// The scopes that the client is requesting access to. + List? _scopes; + + /// An opaque string that users of this library may specify that will be + /// included in the response query parameters. + String? _stateString; + + /// The current state of the grant object. + _State _state = _State.initial; + + /// Allowed characters for generating the _codeVerifier + static const String _charset = + 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~'; + + /// The PKCE code verifier. Will be generated if one is not provided in the + /// constructor. + final String _codeVerifier; + + /// Creates a new grant. + /// + /// If [basicAuth] is `true` (the default), the client credentials are sent to + /// the server using using HTTP Basic authentication as defined in [RFC 2617]. + /// Otherwise, they're included in the request body. Note that the latter form + /// is not recommended by the OAuth 2.0 spec, and should only be used if the + /// server doesn't support Basic authentication. + /// + /// [RFC 2617]: https://tools.ietf.org/html/rfc2617 + /// + /// [httpClient] is used for all HTTP requests made by this grant, as well as + /// those of the [Client] is constructs. + /// + /// [onCredentialsRefreshed] will be called by the constructed [Client] + /// whenever the credentials are refreshed. + /// + /// [codeVerifier] String to be used as PKCE code verifier. If none is + /// provided a random codeVerifier will be generated. + /// The codeVerifier must meet requirements specified in [RFC 7636]. + /// + /// [RFC 7636]: https://tools.ietf.org/html/rfc7636#section-4.1 + /// + /// The scope strings will be separated by the provided [delimiter]. This + /// defaults to `" "`, the OAuth2 standard, but some APIs (such as Facebook's) + /// use non-standard delimiters. + /// + /// By default, this follows the OAuth2 spec and requires the server's + /// responses to be in JSON format. However, some servers return non-standard + /// response formats, which can be parsed using the [getParameters] function. + /// + /// This function is passed the `Content-Type` header of the response as well + /// as its body as a UTF-8-decoded string. It should return a map in the same + /// format as the [standard JSON response][]. + /// + /// [standard JSON response]: https://tools.ietf.org/html/rfc6749#section-5.1 + AuthorizationCodeGrant( + this.identifier, this.authorizationEndpoint, this.tokenEndpoint, + {this.secret, + String? delimiter, + bool basicAuth = true, + http.Client? httpClient, + CredentialsRefreshedCallback? onCredentialsRefreshed, + Map Function(MediaType? contentType, String body)? + getParameters, + String? codeVerifier}) + : _basicAuth = basicAuth, + _httpClient = httpClient ?? http.Client(), + _delimiter = delimiter ?? ' ', + _getParameters = getParameters ?? parseJsonParameters, + _onCredentialsRefreshed = onCredentialsRefreshed, + _codeVerifier = codeVerifier ?? _createCodeVerifier(); + + /// Returns the URL to which the resource owner should be redirected to + /// authorize this client. + /// + /// The resource owner will then be redirected to [redirect], which should + /// point to a server controlled by the client. This redirect will have + /// additional query parameters that should be passed to + /// [handleAuthorizationResponse]. + /// + /// The specific permissions being requested from the authorization server may + /// be specified via [scopes]. The scope strings are specific to the + /// authorization server and may be found in its documentation. Note that you + /// may not be granted access to every scope you request; you may check the + /// [Credentials.scopes] field of [Client.credentials] to see which scopes you + /// were granted. + /// + /// An opaque [state] string may also be passed that will be present in the + /// query parameters provided to the redirect URL. + /// + /// It is a [StateError] to call this more than once. + Uri getAuthorizationUrl(Uri redirect, + {Iterable? scopes, String? state}) { + if (_state != _State.initial) { + throw StateError('The authorization URL has already been generated.'); + } + _state = _State.awaitingResponse; + + var scopeList = scopes?.toList() ?? []; + var codeChallenge = base64Url + .encode(sha256.convert(ascii.encode(_codeVerifier)).bytes) + .replaceAll('=', ''); + + _redirectEndpoint = redirect; + _scopes = scopeList; + _stateString = state; + var parameters = { + 'response_type': 'code', + 'client_id': identifier, + 'redirect_uri': redirect.toString(), + 'code_challenge': codeChallenge, + 'code_challenge_method': 'S256' + }; + + if (state != null) parameters['state'] = state; + if (scopeList.isNotEmpty) parameters['scope'] = scopeList.join(_delimiter); + + return addQueryParameters(authorizationEndpoint, parameters); + } + + /// Processes the query parameters added to a redirect from the authorization + /// server. + /// + /// Note that this "response" is not an HTTP response, but rather the data + /// passed to a server controlled by the client as query parameters on the + /// redirect URL. + /// + /// It is a [StateError] to call this more than once, to call it before + /// [getAuthorizationUrl] is called, or to call it after + /// [handleAuthorizationCode] is called. + /// + /// Throws [FormatException] if [parameters] is invalid according to the + /// OAuth2 spec or if the authorization server otherwise provides invalid + /// responses. If `state` was passed to [getAuthorizationUrl], this will throw + /// a [FormatException] if the `state` parameter doesn't match the original + /// value. + /// + /// Throws [AuthorizationException] if the authorization fails. + Future handleAuthorizationResponse( + Map parameters) async { + if (_state == _State.initial) { + throw StateError('The authorization URL has not yet been generated.'); + } else if (_state == _State.finished) { + throw StateError('The authorization code has already been received.'); + } + _state = _State.finished; + + if (_stateString != null) { + if (!parameters.containsKey('state')) { + throw FormatException('Invalid OAuth response for ' + '"$authorizationEndpoint": parameter "state" expected to be ' + '"$_stateString", was missing.'); + } else if (parameters['state'] != _stateString) { + throw FormatException('Invalid OAuth response for ' + '"$authorizationEndpoint": parameter "state" expected to be ' + '"$_stateString", was "${parameters['state']}".'); + } + } + + if (parameters.containsKey('error')) { + var description = parameters['error_description']; + var uriString = parameters['error_uri']; + var uri = uriString == null ? null : Uri.parse(uriString); + throw AuthorizationException(parameters['error']!, description, uri); + } else if (!parameters.containsKey('code')) { + throw FormatException('Invalid OAuth response for ' + '"$authorizationEndpoint": did not contain required parameter ' + '"code".'); + } + + return _handleAuthorizationCode(parameters['code']); + } + + /// Processes an authorization code directly. + /// + /// Usually [handleAuthorizationResponse] is preferable to this method, since + /// it validates all of the query parameters. However, some authorization + /// servers allow the user to copy and paste an authorization code into a + /// command-line application, in which case this method must be used. + /// + /// It is a [StateError] to call this more than once, to call it before + /// [getAuthorizationUrl] is called, or to call it after + /// [handleAuthorizationCode] is called. + /// + /// Throws [FormatException] if the authorization server provides invalid + /// responses while retrieving credentials. + /// + /// Throws [AuthorizationException] if the authorization fails. + Future handleAuthorizationCode(String authorizationCode) async { + if (_state == _State.initial) { + throw StateError('The authorization URL has not yet been generated.'); + } else if (_state == _State.finished) { + throw StateError('The authorization code has already been received.'); + } + _state = _State.finished; + + return _handleAuthorizationCode(authorizationCode); + } + + /// This works just like [handleAuthorizationCode], except it doesn't validate + /// the state beforehand. + Future _handleAuthorizationCode(String? authorizationCode) async { + var startTime = DateTime.now(); + + var headers = {}; + + var body = { + 'grant_type': 'authorization_code', + 'code': authorizationCode, + 'redirect_uri': _redirectEndpoint.toString(), + 'code_verifier': _codeVerifier + }; + + var secret = this.secret; + if (_basicAuth && secret != null) { + headers['Authorization'] = basicAuthHeader(identifier, secret); + } else { + // The ID is required for this request any time basic auth isn't being + // used, even if there's no actual client authentication to be done. + body['client_id'] = identifier; + if (secret != null) body['client_secret'] = secret; + } + + var response = + await _httpClient!.post(tokenEndpoint, headers: headers, body: body); + + var credentials = handleAccessTokenResponse( + response, tokenEndpoint, startTime, _scopes, _delimiter, + getParameters: _getParameters); + return Client(credentials, + identifier: identifier, + secret: secret, + basicAuth: _basicAuth, + httpClient: _httpClient, + onCredentialsRefreshed: _onCredentialsRefreshed); + } + + // Randomly generate a 128 character string to be used as the PKCE code + // verifier. + static String _createCodeVerifier() => List.generate( + 128, + (i) => _charset[Random.secure().nextInt(_charset.length)], + ).join(); + + /// Closes the grant and frees its resources. + /// + /// This will close the underlying HTTP client, which is shared by the + /// [Client] created by this grant, so it's not safe to close the grant and + /// continue using the client. + void close() { + _httpClient?.close(); + _httpClient = null; + } +} + +/// States that [AuthorizationCodeGrant] can be in. +class _State { + /// [AuthorizationCodeGrant.getAuthorizationUrl] has not yet been called for + /// this grant. + static const initial = _State('initial'); + + // [AuthorizationCodeGrant.getAuthorizationUrl] has been called but neither + // [AuthorizationCodeGrant.handleAuthorizationResponse] nor + // [AuthorizationCodeGrant.handleAuthorizationCode] has been called. + static const awaitingResponse = _State('awaiting response'); + + // [AuthorizationCodeGrant.getAuthorizationUrl] and either + // [AuthorizationCodeGrant.handleAuthorizationResponse] or + // [AuthorizationCodeGrant.handleAuthorizationCode] have been called. + static const finished = _State('finished'); + + final String _name; + + const _State(this._name); + + @override + String toString() => _name; +} diff --git a/pkgs/oauth2/lib/src/authorization_exception.dart b/pkgs/oauth2/lib/src/authorization_exception.dart new file mode 100644 index 000000000..14a5a3c00 --- /dev/null +++ b/pkgs/oauth2/lib/src/authorization_exception.dart @@ -0,0 +1,39 @@ +// Copyright (c) 2012, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +/// An exception raised when OAuth2 authorization fails. +class AuthorizationException implements Exception { + /// The name of the error. + /// + /// Possible names are enumerated in [the spec][]. + /// + /// [the spec]: http://tools.ietf.org/html/draft-ietf-oauth-v2-31#section-5.2 + final String error; + + /// The description of the error, provided by the server. + /// + /// May be `null` if the server provided no description. + final String? description; + + /// A URL for a page that describes the error in more detail, provided by the + /// server. + /// + /// May be `null` if the server provided no URL. + final Uri? uri; + + /// Creates an AuthorizationException. + AuthorizationException(this.error, this.description, this.uri); + + /// Provides a string description of the AuthorizationException. + @override + String toString() { + var header = 'OAuth authorization error ($error)'; + if (description != null) { + header = '$header: $description'; + } else if (uri != null) { + header = '$header: $uri'; + } + return '$header.'; + } +} diff --git a/pkgs/oauth2/lib/src/client.dart b/pkgs/oauth2/lib/src/client.dart new file mode 100644 index 000000000..1dd2282fb --- /dev/null +++ b/pkgs/oauth2/lib/src/client.dart @@ -0,0 +1,187 @@ +// Copyright (c) 2012, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'dart:async'; + +import 'package:collection/collection.dart'; +import 'package:http/http.dart' as http; +import 'package:http_parser/http_parser.dart'; + +import 'authorization_exception.dart'; +import 'credentials.dart'; +import 'expiration_exception.dart'; + +/// An OAuth2 client. +/// +/// This acts as a drop-in replacement for an [http.Client], while sending +/// OAuth2 authorization credentials along with each request. +/// +/// The client also automatically refreshes its credentials if possible. When it +/// makes a request, if its credentials are expired, it will first refresh them. +/// This means that any request may throw an [AuthorizationException] if the +/// refresh is not authorized for some reason, a [FormatException] if the +/// authorization server provides ill-formatted responses, or an +/// [ExpirationException] if the credentials are expired and can't be refreshed. +/// +/// The client will also throw an [AuthorizationException] if the resource +/// server returns a 401 response with a WWW-Authenticate header indicating that +/// the current credentials are invalid. +/// +/// If you already have a set of [Credentials], you can construct a [Client] +/// directly. However, in order to first obtain the credentials, you must +/// authorize. At the time of writing, the only authorization method this +/// library supports is [AuthorizationCodeGrant]. +class Client extends http.BaseClient { + /// The client identifier for this client. + /// + /// The authorization server will issue each client a separate client + /// identifier and secret, which allows the server to tell which client is + /// accessing it. Some servers may also have an anonymous identifier/secret + /// pair that any client may use. + /// + /// This is usually global to the program using this library. + final String? identifier; + + /// The client secret for this client. + /// + /// The authorization server will issue each client a separate client + /// identifier and secret, which allows the server to tell which client is + /// accessing it. Some servers may also have an anonymous identifier/secret + /// pair that any client may use. + /// + /// This is usually global to the program using this library. + /// + /// Note that clients whose source code or binary executable is readily + /// available may not be able to make sure the client secret is kept a secret. + /// This is fine; OAuth2 servers generally won't rely on knowing with + /// certainty that a client is who it claims to be. + final String? secret; + + /// The credentials this client uses to prove to the resource server that it's + /// authorized. + /// + /// This may change from request to request as the credentials expire and the + /// client refreshes them automatically. + Credentials get credentials => _credentials; + Credentials _credentials; + + /// Callback to be invoked whenever the credentials refreshed. + final CredentialsRefreshedCallback? _onCredentialsRefreshed; + + /// Whether to use HTTP Basic authentication for authorizing the client. + final bool _basicAuth; + + /// The underlying HTTP client. + http.Client? _httpClient; + + /// Creates a new client from a pre-existing set of credentials. + /// + /// When authorizing a client for the first time, you should use + /// [AuthorizationCodeGrant] or [resourceOwnerPasswordGrant] instead of + /// constructing a [Client] directly. + /// + /// [httpClient] is the underlying client that this forwards requests to after + /// adding authorization credentials to them. + /// + /// Throws an [ArgumentError] if [secret] is passed without [identifier]. + Client(this._credentials, + {this.identifier, + this.secret, + CredentialsRefreshedCallback? onCredentialsRefreshed, + bool basicAuth = true, + http.Client? httpClient}) + : _basicAuth = basicAuth, + _onCredentialsRefreshed = onCredentialsRefreshed, + _httpClient = httpClient ?? http.Client() { + if (identifier == null && secret != null) { + throw ArgumentError('secret may not be passed without identifier.'); + } + } + + /// Sends an HTTP request with OAuth2 authorization credentials attached. + /// + /// This will also automatically refresh this client's [Credentials] before + /// sending the request if necessary. + @override + Future send(http.BaseRequest request) async { + if (credentials.isExpired) { + if (!credentials.canRefresh) throw ExpirationException(credentials); + await refreshCredentials(); + } + + request.headers['authorization'] = 'Bearer ${credentials.accessToken}'; + var response = await _httpClient!.send(request); + + if (response.statusCode != 401) return response; + if (!response.headers.containsKey('www-authenticate')) return response; + + List challenges; + try { + challenges = AuthenticationChallenge.parseHeader( + response.headers['www-authenticate']!); + } on FormatException { + return response; + } + + var challenge = challenges + .firstWhereOrNull((challenge) => challenge.scheme == 'bearer'); + if (challenge == null) return response; + + var params = challenge.parameters; + if (!params.containsKey('error')) return response; + + throw AuthorizationException(params['error']!, params['error_description'], + params['error_uri'] == null ? null : Uri.parse(params['error_uri']!)); + } + + /// A [Future] used to track whether [refreshCredentials] is running. + Future? _refreshingFuture; + + /// Explicitly refreshes this client's credentials. Returns this client. + /// + /// This will throw a [StateError] if the [Credentials] can't be refreshed, an + /// [AuthorizationException] if refreshing the credentials fails, or a + /// [FormatException] if the authorization server returns invalid responses. + /// + /// You may request different scopes than the default by passing in + /// [newScopes]. These must be a subset of the scopes in the + /// [Credentials.scopes] field of [Client.credentials]. + Future refreshCredentials([List? newScopes]) async { + if (!credentials.canRefresh) { + var prefix = 'OAuth credentials'; + if (credentials.isExpired) prefix = '$prefix have expired and'; + throw StateError("$prefix can't be refreshed."); + } + + // To make sure that only one refresh happens when credentials are expired + // we track it using the [_refreshingFuture]. And also make sure that the + // _onCredentialsRefreshed callback is only called once. + if (_refreshingFuture == null) { + try { + _refreshingFuture = credentials.refresh( + identifier: identifier, + secret: secret, + newScopes: newScopes, + basicAuth: _basicAuth, + httpClient: _httpClient, + ); + _credentials = await _refreshingFuture!; + _onCredentialsRefreshed?.call(_credentials); + } finally { + _refreshingFuture = null; + } + } else { + await _refreshingFuture; + } + + return this; + } + + /// Closes this client and its underlying HTTP client. + @override + void close() { + _httpClient?.close(); + _httpClient = null; + } +} diff --git a/pkgs/oauth2/lib/src/client_credentials_grant.dart b/pkgs/oauth2/lib/src/client_credentials_grant.dart new file mode 100644 index 000000000..045d1a086 --- /dev/null +++ b/pkgs/oauth2/lib/src/client_credentials_grant.dart @@ -0,0 +1,79 @@ +// Copyright (c) 2012, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'dart:async'; + +import 'package:http/http.dart' as http; +import 'package:http_parser/http_parser.dart'; + +import 'client.dart'; +import 'handle_access_token_response.dart'; +import 'utils.dart'; + +/// Obtains credentials using a [client credentials grant](https://tools.ietf.org/html/rfc6749#section-1.3.4). +/// +/// This mode of authorization uses the client's [identifier] and [secret] +/// to obtain an authorization token from the authorization server, instead +/// of sending a user through a dedicated flow. +/// +/// The client [identifier] and [secret] are required, and are +/// used to identify and authenticate your specific OAuth2 client. These are +/// usually global to the program using this library. +/// +/// The specific permissions being requested from the authorization server may +/// be specified via [scopes]. The scope strings are specific to the +/// authorization server and may be found in its documentation. Note that you +/// may not be granted access to every scope you request; you may check the +/// [Credentials.scopes] field of [Client.credentials] to see which scopes you +/// were granted. +/// +/// The scope strings will be separated by the provided [delimiter]. This +/// defaults to `" "`, the OAuth2 standard, but some APIs (such as Facebook's) +/// use non-standard delimiters. +/// +/// By default, this follows the OAuth2 spec and requires the server's responses +/// to be in JSON format. However, some servers return non-standard response +/// formats, which can be parsed using the [getParameters] function. +/// +/// This function is passed the `Content-Type` header of the response as well as +/// its body as a UTF-8-decoded string. It should return a map in the same +/// format as the [standard JSON response](https://tools.ietf.org/html/rfc6749#section-5.1) +Future clientCredentialsGrant( + Uri authorizationEndpoint, String? identifier, String? secret, + {Iterable? scopes, + bool basicAuth = true, + http.Client? httpClient, + String? delimiter, + Map Function(MediaType? contentType, String body)? + getParameters}) async { + delimiter ??= ' '; + var startTime = DateTime.now(); + + var body = {'grant_type': 'client_credentials'}; + + var headers = {}; + + if (identifier != null) { + if (basicAuth) { + headers['Authorization'] = basicAuthHeader(identifier, secret!); + } else { + body['client_id'] = identifier; + if (secret != null) body['client_secret'] = secret; + } + } + + if (scopes != null && scopes.isNotEmpty) { + body['scope'] = scopes.join(delimiter); + } + + httpClient ??= http.Client(); + var response = await httpClient.post(authorizationEndpoint, + headers: headers, body: body); + + var credentials = handleAccessTokenResponse(response, authorizationEndpoint, + startTime, scopes?.toList() ?? [], delimiter, + getParameters: getParameters); + return Client(credentials, + identifier: identifier, secret: secret, httpClient: httpClient); +} diff --git a/pkgs/oauth2/lib/src/credentials.dart b/pkgs/oauth2/lib/src/credentials.dart new file mode 100644 index 000000000..088b482fb --- /dev/null +++ b/pkgs/oauth2/lib/src/credentials.dart @@ -0,0 +1,267 @@ +// Copyright (c) 2012, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'dart:async'; +import 'dart:collection'; +import 'dart:convert'; + +import 'package:http/http.dart' as http; +import 'package:http_parser/http_parser.dart'; + +import 'handle_access_token_response.dart'; +import 'parameters.dart'; +import 'utils.dart'; + +/// Type of the callback when credentials are refreshed. +typedef CredentialsRefreshedCallback = void Function(Credentials); + +/// Credentials that prove that a client is allowed to access a resource on the +/// resource owner's behalf. +/// +/// These credentials are long-lasting and can be safely persisted across +/// multiple runs of the program. +/// +/// Many authorization servers will attach an expiration date to a set of +/// credentials, along with a token that can be used to refresh the credentials +/// once they've expired. The [http.Client] will automatically refresh its +/// credentials when necessary. It's also possible to explicitly refresh them +/// via [http.Client.refreshCredentials] or [Credentials.refresh]. +/// +/// Note that a given set of credentials can only be refreshed once, so be sure +/// to save the refreshed credentials for future use. +class Credentials { + /// A [String] used to separate scopes; defaults to `" "`. + String _delimiter; + + /// The token that is sent to the resource server to prove the authorization + /// of a client. + final String accessToken; + + /// The token that is sent to the authorization server to refresh the + /// credentials. + /// + /// This may be `null`, indicating that the credentials can't be refreshed. + final String? refreshToken; + + /// The token that is received from the authorization server to enable + /// End-Users to be Authenticated, contains Claims, represented as a + /// JSON Web Token (JWT). + /// + /// This may be `null`, indicating that the 'openid' scope was not + /// requested (or not supported). + /// + /// [spec]: https://openid.net/specs/openid-connect-core-1_0.html#IDToken + final String? idToken; + + /// The URL of the authorization server endpoint that's used to refresh the + /// credentials. + /// + /// This may be `null`, indicating that the credentials can't be refreshed. + final Uri? tokenEndpoint; + + /// The specific permissions being requested from the authorization server. + /// + /// The scope strings are specific to the authorization server and may be + /// found in its documentation. + final List? scopes; + + /// The date at which these credentials will expire. + /// + /// This is likely to be a few seconds earlier than the server's idea of the + /// expiration date. + final DateTime? expiration; + + /// The function used to parse parameters from a host's response. + final GetParameters _getParameters; + + /// Whether or not these credentials have expired. + /// + /// Note that it's possible the credentials will expire shortly after this is + /// called. However, since the client's expiration date is kept a few seconds + /// earlier than the server's, there should be enough leeway to rely on this. + bool get isExpired { + var expiration = this.expiration; + return expiration != null && DateTime.now().isAfter(expiration); + } + + /// Whether it's possible to refresh these credentials. + bool get canRefresh => refreshToken != null && tokenEndpoint != null; + + /// Creates a new set of credentials. + /// + /// This class is usually not constructed directly; rather, it's accessed via + /// [Client.credentials] after a [Client] is created by + /// [AuthorizationCodeGrant]. Alternately, it may be loaded from a serialized + /// form via [Credentials.fromJson]. + /// + /// The scope strings will be separated by the provided [delimiter]. This + /// defaults to `" "`, the OAuth2 standard, but some APIs (such as Facebook's) + /// use non-standard delimiters. + /// + /// By default, this follows the OAuth2 spec and requires the server's + /// responses to be in JSON format. However, some servers return non-standard + /// response formats, which can be parsed using the [getParameters] function. + /// + /// This function is passed the `Content-Type` header of the response as well + /// as its body as a UTF-8-decoded string. It should return a map in the same + /// format as the [standard JSON response][]. + /// + /// [standard JSON response]: https://tools.ietf.org/html/rfc6749#section-5.1 + Credentials(this.accessToken, + {this.refreshToken, + this.idToken, + this.tokenEndpoint, + Iterable? scopes, + this.expiration, + String? delimiter, + Map Function(MediaType? mediaType, String body)? + getParameters}) + : scopes = UnmodifiableListView( + // Explicitly type-annotate the list literal to work around + // sdk#24202. + scopes == null ? [] : scopes.toList()), + _delimiter = delimiter ?? ' ', + _getParameters = getParameters ?? parseJsonParameters; + + /// Loads a set of credentials from a JSON-serialized form. + /// + /// Throws a [FormatException] if the JSON is incorrectly formatted. + factory Credentials.fromJson(String json) { + void validate(bool condition, String message) { + if (condition) return; + throw FormatException('Failed to load credentials: $message.\n\n$json'); + } + + dynamic parsed; + try { + parsed = jsonDecode(json); + } on FormatException { + validate(false, 'invalid JSON'); + } + + validate(parsed is Map, 'was not a JSON map'); + + parsed = parsed as Map; + validate(parsed.containsKey('accessToken'), + 'did not contain required field "accessToken"'); + validate( + parsed['accessToken'] is String, + 'required field "accessToken" was not a string, was ' + '${parsed["accessToken"]}', + ); + + for (var stringField in ['refreshToken', 'idToken', 'tokenEndpoint']) { + var value = parsed[stringField]; + validate(value == null || value is String, + 'field "$stringField" was not a string, was "$value"'); + } + + var scopes = parsed['scopes']; + validate(scopes == null || scopes is List, + 'field "scopes" was not a list, was "$scopes"'); + + var tokenEndpoint = parsed['tokenEndpoint']; + Uri? tokenEndpointUri; + if (tokenEndpoint != null) { + tokenEndpointUri = Uri.parse(tokenEndpoint as String); + } + + var expiration = parsed['expiration']; + DateTime? expirationDateTime; + if (expiration != null) { + validate(expiration is int, + 'field "expiration" was not an int, was "$expiration"'); + expiration = expiration as int; + expirationDateTime = DateTime.fromMillisecondsSinceEpoch(expiration); + } + + return Credentials( + parsed['accessToken'] as String, + refreshToken: parsed['refreshToken'] as String?, + idToken: parsed['idToken'] as String?, + tokenEndpoint: tokenEndpointUri, + scopes: (scopes as List).map((scope) => scope as String), + expiration: expirationDateTime, + ); + } + + /// Serializes a set of credentials to JSON. + /// + /// Nothing is guaranteed about the output except that it's valid JSON and + /// compatible with [Credentials.toJson]. + String toJson() => jsonEncode({ + 'accessToken': accessToken, + 'refreshToken': refreshToken, + 'idToken': idToken, + 'tokenEndpoint': tokenEndpoint?.toString(), + 'scopes': scopes, + 'expiration': expiration?.millisecondsSinceEpoch + }); + + /// Returns a new set of refreshed credentials. + /// + /// See [Client.identifier] and [Client.secret] for explanations of those + /// parameters. + /// + /// You may request different scopes than the default by passing in + /// [newScopes]. These must be a subset of [scopes]. + /// + /// This throws an [ArgumentError] if [secret] is passed without [identifier], + /// a [StateError] if these credentials can't be refreshed, an + /// [AuthorizationException] if refreshing the credentials fails, or a + /// [FormatException] if the authorization server returns invalid responses. + Future refresh( + {String? identifier, + String? secret, + Iterable? newScopes, + bool basicAuth = true, + http.Client? httpClient}) async { + var scopes = this.scopes; + if (newScopes != null) scopes = newScopes.toList(); + scopes ??= []; + httpClient ??= http.Client(); + + if (identifier == null && secret != null) { + throw ArgumentError('secret may not be passed without identifier.'); + } + + var startTime = DateTime.now(); + var tokenEndpoint = this.tokenEndpoint; + if (refreshToken == null) { + throw StateError("Can't refresh credentials without a refresh " + 'token.'); + } else if (tokenEndpoint == null) { + throw StateError("Can't refresh credentials without a token " + 'endpoint.'); + } + + var headers = {}; + + var body = {'grant_type': 'refresh_token', 'refresh_token': refreshToken}; + if (scopes.isNotEmpty) body['scope'] = scopes.join(_delimiter); + + if (basicAuth && secret != null) { + headers['Authorization'] = basicAuthHeader(identifier!, secret); + } else { + if (identifier != null) body['client_id'] = identifier; + if (secret != null) body['client_secret'] = secret; + } + + var response = + await httpClient.post(tokenEndpoint, headers: headers, body: body); + var credentials = handleAccessTokenResponse( + response, tokenEndpoint, startTime, scopes, _delimiter, + getParameters: _getParameters); + + // The authorization server may issue a new refresh token. If it doesn't, + // we should re-use the one we already have. + if (credentials.refreshToken != null) return credentials; + return Credentials(credentials.accessToken, + refreshToken: refreshToken, + idToken: credentials.idToken, + tokenEndpoint: credentials.tokenEndpoint, + scopes: credentials.scopes, + expiration: credentials.expiration); + } +} diff --git a/pkgs/oauth2/lib/src/expiration_exception.dart b/pkgs/oauth2/lib/src/expiration_exception.dart new file mode 100644 index 000000000..d72fcf64c --- /dev/null +++ b/pkgs/oauth2/lib/src/expiration_exception.dart @@ -0,0 +1,19 @@ +// Copyright (c) 2012, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'credentials.dart'; + +/// An exception raised when attempting to use expired OAuth2 credentials. +class ExpirationException implements Exception { + /// The expired credentials. + final Credentials credentials; + + /// Creates an ExpirationException. + ExpirationException(this.credentials); + + /// Provides a string description of the ExpirationException. + @override + String toString() => + "OAuth2 credentials have expired and can't be refreshed."; +} diff --git a/pkgs/oauth2/lib/src/handle_access_token_response.dart b/pkgs/oauth2/lib/src/handle_access_token_response.dart new file mode 100644 index 000000000..f318e3b0c --- /dev/null +++ b/pkgs/oauth2/lib/src/handle_access_token_response.dart @@ -0,0 +1,158 @@ +// Copyright (c) 2012, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'package:http/http.dart' as http; +import 'package:http_parser/http_parser.dart'; + +import 'authorization_exception.dart'; +import 'credentials.dart'; +import 'parameters.dart'; + +/// The amount of time to add as a "grace period" for credential expiration. +/// +/// This allows credential expiration checks to remain valid for a reasonable +/// amount of time. +const _expirationGrace = Duration(seconds: 10); + +/// Handles a response from the authorization server that contains an access +/// token. +/// +/// This response format is common across several different components of the +/// OAuth2 flow. +/// +/// By default, this follows the OAuth2 spec and requires the server's responses +/// to be in JSON format. However, some servers return non-standard response +/// formats, which can be parsed using the [getParameters] function. +/// +/// This function is passed the `Content-Type` header of the response as well as +/// its body as a UTF-8-decoded string. It should return a map in the same +/// format as the [standard JSON response][]. +/// +/// [standard JSON response]: https://tools.ietf.org/html/rfc6749#section-5.1 +Credentials handleAccessTokenResponse(http.Response response, Uri tokenEndpoint, + DateTime startTime, List? scopes, String delimiter, + {Map Function(MediaType? contentType, String body)? + getParameters}) { + getParameters ??= parseJsonParameters; + + try { + if (response.statusCode != 200) { + _handleErrorResponse(response, tokenEndpoint, getParameters); + } + + var contentTypeString = response.headers['content-type']; + if (contentTypeString == null) { + throw const FormatException('Missing Content-Type string.'); + } + + var parameters = + getParameters(MediaType.parse(contentTypeString), response.body); + + for (var requiredParameter in ['access_token', 'token_type']) { + if (!parameters.containsKey(requiredParameter)) { + throw FormatException( + 'did not contain required parameter "$requiredParameter"'); + } else if (parameters[requiredParameter] is! String) { + throw FormatException( + 'required parameter "$requiredParameter" was not a string, was ' + '"${parameters[requiredParameter]}"'); + } + } + + // TODO(nweiz): support the "mac" token type + // (http://tools.ietf.org/html/draft-ietf-oauth-v2-http-mac-01) + if ((parameters['token_type'] as String).toLowerCase() != 'bearer') { + throw FormatException( + '"$tokenEndpoint": unknown token type "${parameters['token_type']}"'); + } + + var expiresIn = parameters['expires_in']; + if (expiresIn != null) { + if (expiresIn is String) { + try { + expiresIn = double.parse(expiresIn).toInt(); + } on FormatException { + throw FormatException( + 'parameter "expires_in" could not be parsed as in, was: ' + '"$expiresIn"', + ); + } + } else if (expiresIn is! int) { + throw FormatException( + 'parameter "expires_in" was not an int, was: "$expiresIn"'); + } + } + + for (var name in ['refresh_token', 'id_token', 'scope']) { + var value = parameters[name]; + if (value != null && value is! String) { + throw FormatException( + 'parameter "$name" was not a string, was "$value"'); + } + } + + var scope = parameters['scope'] as String?; + if (scope != null) scopes = scope.split(delimiter); + + var expiration = expiresIn == null + ? null + : startTime.add(Duration(seconds: expiresIn as int) - _expirationGrace); + + return Credentials( + parameters['access_token'] as String, + refreshToken: parameters['refresh_token'] as String?, + idToken: parameters['id_token'] as String?, + tokenEndpoint: tokenEndpoint, + scopes: scopes, + expiration: expiration, + ); + } on FormatException catch (e) { + throw FormatException('Invalid OAuth response for "$tokenEndpoint": ' + '${e.message}.\n\n${response.body}'); + } +} + +/// Throws the appropriate exception for an error response from the +/// authorization server. +void _handleErrorResponse( + http.Response response, Uri tokenEndpoint, GetParameters getParameters) { + // OAuth2 mandates a 400 or 401 response code for access token error + // responses. If it's not a 400 reponse, the server is either broken or + // off-spec. + if (response.statusCode != 400 && response.statusCode != 401) { + var reason = ''; + var reasonPhrase = response.reasonPhrase; + if (reasonPhrase != null && reasonPhrase.isNotEmpty) { + reason = ' $reasonPhrase'; + } + throw FormatException('OAuth request for "$tokenEndpoint" failed ' + 'with status ${response.statusCode}$reason.\n\n${response.body}'); + } + + var contentTypeString = response.headers['content-type']; + var contentType = + contentTypeString == null ? null : MediaType.parse(contentTypeString); + + var parameters = getParameters(contentType, response.body); + + if (!parameters.containsKey('error')) { + throw const FormatException('did not contain required parameter "error"'); + } else if (parameters['error'] is! String) { + throw FormatException('required parameter "error" was not a string, was ' + '"${parameters["error"]}"'); + } + + for (var name in ['error_description', 'error_uri']) { + var value = parameters[name]; + + if (value != null && value is! String) { + throw FormatException('parameter "$name" was not a string, was "$value"'); + } + } + + var uriString = parameters['error_uri'] as String?; + var uri = uriString == null ? null : Uri.parse(uriString); + var description = parameters['error_description'] as String?; + throw AuthorizationException(parameters['error'] as String, description, uri); +} diff --git a/pkgs/oauth2/lib/src/parameters.dart b/pkgs/oauth2/lib/src/parameters.dart new file mode 100644 index 000000000..ecc655978 --- /dev/null +++ b/pkgs/oauth2/lib/src/parameters.dart @@ -0,0 +1,33 @@ +// Copyright (c) 2018, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'dart:convert'; + +import 'package:http_parser/http_parser.dart'; + +/// The type of a callback that parses parameters from an HTTP response. +typedef GetParameters = Map Function( + MediaType? contentType, String body); + +/// Parses parameters from a response with a JSON body, as per the +/// [OAuth2 spec][]. +/// +/// [OAuth2 spec]: https://tools.ietf.org/html/rfc6749#section-5.1 +Map parseJsonParameters(MediaType? contentType, String body) { + // The spec requires a content-type of application/json, but some endpoints + // (e.g. Dropbox) serve it as text/javascript instead. + if (contentType == null || + (contentType.mimeType != 'application/json' && + contentType.mimeType != 'text/javascript')) { + throw FormatException( + 'Content-Type was "$contentType", expected "application/json"'); + } + + var untypedParameters = jsonDecode(body); + if (untypedParameters is Map) { + return untypedParameters; + } + + throw FormatException('Parameters must be a map, was "$untypedParameters"'); +} diff --git a/pkgs/oauth2/lib/src/resource_owner_password_grant.dart b/pkgs/oauth2/lib/src/resource_owner_password_grant.dart new file mode 100644 index 000000000..96fb5037b --- /dev/null +++ b/pkgs/oauth2/lib/src/resource_owner_password_grant.dart @@ -0,0 +1,94 @@ +// Copyright (c) 2012, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'dart:async'; + +import 'package:http/http.dart' as http; +import 'package:http_parser/http_parser.dart'; + +import 'client.dart'; +import 'credentials.dart'; +import 'handle_access_token_response.dart'; +import 'utils.dart'; + +/// Obtains credentials using a [resource owner password grant](https://tools.ietf.org/html/rfc6749#section-1.3.3). +/// +/// This mode of authorization uses the user's username and password to obtain +/// an authentication token, which can then be stored. This is safer than +/// storing the username and password directly, but it should be avoided if any +/// other authorization method is available, since it requires the user to +/// provide their username and password to a third party (you). +/// +/// The client [identifier] and [secret] may be issued by the server, and are +/// used to identify and authenticate your specific OAuth2 client. These are +/// usually global to the program using this library. +/// +/// The specific permissions being requested from the authorization server may +/// be specified via [scopes]. The scope strings are specific to the +/// authorization server and may be found in its documentation. Note that you +/// may not be granted access to every scope you request; you may check the +/// [Credentials.scopes] field of [Client.credentials] to see which scopes you +/// were granted. +/// +/// The scope strings will be separated by the provided [delimiter]. This +/// defaults to `" "`, the OAuth2 standard, but some APIs (such as Facebook's) +/// use non-standard delimiters. +/// +/// By default, this follows the OAuth2 spec and requires the server's responses +/// to be in JSON format. However, some servers return non-standard response +/// formats, which can be parsed using the [getParameters] function. +/// +/// This function is passed the `Content-Type` header of the response as well as +/// its body as a UTF-8-decoded string. It should return a map in the same +/// format as the [standard JSON response][]. +/// +/// [standard JSON response]: https://tools.ietf.org/html/rfc6749#section-5.1 +Future resourceOwnerPasswordGrant( + Uri authorizationEndpoint, String username, String password, + {String? identifier, + String? secret, + Iterable? scopes, + bool basicAuth = true, + CredentialsRefreshedCallback? onCredentialsRefreshed, + http.Client? httpClient, + String? delimiter, + Map Function(MediaType? contentType, String body)? + getParameters}) async { + delimiter ??= ' '; + var startTime = DateTime.now(); + + var body = { + 'grant_type': 'password', + 'username': username, + 'password': password + }; + + var headers = {}; + + if (identifier != null) { + if (basicAuth) { + headers['Authorization'] = basicAuthHeader(identifier, secret!); + } else { + body['client_id'] = identifier; + if (secret != null) body['client_secret'] = secret; + } + } + + if (scopes != null && scopes.isNotEmpty) { + body['scope'] = scopes.join(delimiter); + } + + httpClient ??= http.Client(); + var response = await httpClient.post(authorizationEndpoint, + headers: headers, body: body); + + var credentials = handleAccessTokenResponse( + response, authorizationEndpoint, startTime, scopes?.toList(), delimiter, + getParameters: getParameters); + return Client(credentials, + identifier: identifier, + secret: secret, + httpClient: httpClient, + onCredentialsRefreshed: onCredentialsRefreshed); +} diff --git a/pkgs/oauth2/lib/src/utils.dart b/pkgs/oauth2/lib/src/utils.dart new file mode 100644 index 000000000..2a22b9fa5 --- /dev/null +++ b/pkgs/oauth2/lib/src/utils.dart @@ -0,0 +1,15 @@ +// Copyright (c) 2012, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'dart:convert'; + +/// Adds additional query parameters to [url], overwriting the original +/// parameters if a name conflict occurs. +Uri addQueryParameters(Uri url, Map parameters) => url.replace( + queryParameters: Map.from(url.queryParameters)..addAll(parameters)); + +String basicAuthHeader(String identifier, String secret) { + var userPass = '${Uri.encodeFull(identifier)}:${Uri.encodeFull(secret)}'; + return 'Basic ${base64Encode(ascii.encode(userPass))}'; +} diff --git a/pkgs/oauth2/pubspec.yaml b/pkgs/oauth2/pubspec.yaml new file mode 100644 index 000000000..9c89b7bd4 --- /dev/null +++ b/pkgs/oauth2/pubspec.yaml @@ -0,0 +1,20 @@ +name: oauth2 +version: 2.0.3 +description: >- + A client library for authenticating with a remote service via OAuth2 on + behalf of a user, and making authorized HTTP requests with the user's + OAuth2 credentials. +repository: https://github.com/dart-lang/tools/tree/main/pkgs/oauth2 + +environment: + sdk: ^3.0.0 + +dependencies: + collection: ^1.15.0 + crypto: ^3.0.0 + http: ^1.0.0 + http_parser: ^4.0.0 + +dev_dependencies: + dart_flutter_team_lints: ^2.0.0 + test: ^1.16.0 diff --git a/pkgs/oauth2/test/authorization_code_grant_test.dart b/pkgs/oauth2/test/authorization_code_grant_test.dart new file mode 100644 index 000000000..06e88afdd --- /dev/null +++ b/pkgs/oauth2/test/authorization_code_grant_test.dart @@ -0,0 +1,391 @@ +// Copyright (c) 2012, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'dart:async'; +import 'dart:convert'; + +import 'package:http/http.dart' as http; +import 'package:oauth2/oauth2.dart' as oauth2; +import 'package:test/test.dart'; + +import 'utils.dart'; + +final redirectUrl = Uri.parse('http://example.com/redirect'); + +void main() { + late ExpectClient client; + late oauth2.AuthorizationCodeGrant grant; + setUp(() { + client = ExpectClient(); + grant = oauth2.AuthorizationCodeGrant( + 'identifier', + Uri.parse('https://example.com/authorization'), + Uri.parse('https://example.com/token'), + secret: 'secret', + httpClient: client); + }); + + group('.getAuthorizationUrl', () { + test('builds the correct URL', () { + expect( + grant.getAuthorizationUrl(redirectUrl).toString(), + allOf([ + startsWith('https://example.com/authorization?response_type=code'), + contains('&client_id=identifier'), + contains('&redirect_uri=http%3A%2F%2Fexample.com%2Fredirect'), + matches(r'&code_challenge=[A-Za-z0-9\+\/\-\_]{43}'), + contains('&code_challenge_method=S256') + ])); + }); + + test('builds the correct URL with scopes', () { + var authorizationUrl = grant + .getAuthorizationUrl(redirectUrl, scopes: ['scope', 'other/scope']); + expect( + authorizationUrl.toString(), + allOf([ + startsWith('https://example.com/authorization?response_type=code'), + contains('&client_id=identifier'), + contains('&redirect_uri=http%3A%2F%2Fexample.com%2Fredirect'), + matches(r'&code_challenge=[A-Za-z0-9\+\/\-\_]{43}'), + contains('&code_challenge_method=S256'), + contains('&scope=scope+other%2Fscope') + ])); + }); + + test('builds the correct URL with passed in code verifier', () { + const codeVerifier = + 'it1shei7LooGoh3looxaa4sieveijeib2zecauz2oo8aebae5aehee0ahPirewoh' + '5Bo6Maexooqui3uL2si6ahweiv7shauc1shahxooveoB3aeyahsaiye0Egh3raix'; + const expectedCodeChallenge = + 'EjfFMv8TFPd3GuNxAn5COhlWBGpfZLimHett7ypJfJ0'; + var grant = oauth2.AuthorizationCodeGrant( + 'identifier', + Uri.parse('https://example.com/authorization'), + Uri.parse('https://example.com/token'), + secret: 'secret', + httpClient: client, + codeVerifier: codeVerifier); + expect( + grant.getAuthorizationUrl(redirectUrl).toString(), + allOf([ + startsWith('https://example.com/authorization?response_type=code'), + contains('&client_id=identifier'), + contains('&redirect_uri=http%3A%2F%2Fexample.com%2Fredirect'), + contains('&code_challenge=$expectedCodeChallenge'), + contains('&code_challenge_method=S256') + ])); + }); + + test('separates scopes with the correct delimiter', () { + var grant = oauth2.AuthorizationCodeGrant( + 'identifier', + Uri.parse('https://example.com/authorization'), + Uri.parse('https://example.com/token'), + secret: 'secret', + httpClient: client, + delimiter: '_'); + var authorizationUrl = grant + .getAuthorizationUrl(redirectUrl, scopes: ['scope', 'other/scope']); + expect( + authorizationUrl.toString(), + allOf([ + startsWith('https://example.com/authorization?response_type=code'), + contains('&client_id=identifier'), + contains('&redirect_uri=http%3A%2F%2Fexample.com%2Fredirect'), + matches(r'&code_challenge=[A-Za-z0-9\+\/\-\_]{43}'), + contains('&code_challenge_method=S256'), + contains('&scope=scope_other%2Fscope') + ])); + }); + + test('builds the correct URL with state', () { + var authorizationUrl = + grant.getAuthorizationUrl(redirectUrl, state: 'state'); + expect( + authorizationUrl.toString(), + allOf([ + startsWith('https://example.com/authorization?response_type=code'), + contains('&client_id=identifier'), + contains('&redirect_uri=http%3A%2F%2Fexample.com%2Fredirect'), + matches(r'&code_challenge=[A-Za-z0-9\+\/\-\_]{43}'), + contains('&code_challenge_method=S256'), + contains('&state=state') + ])); + }); + + test('merges with existing query parameters', () { + grant = oauth2.AuthorizationCodeGrant( + 'identifier', + Uri.parse('https://example.com/authorization?query=value'), + Uri.parse('https://example.com/token'), + secret: 'secret', + httpClient: client); + + var authorizationUrl = grant.getAuthorizationUrl(redirectUrl); + expect( + authorizationUrl.toString(), + allOf([ + startsWith('https://example.com/authorization?query=value'), + contains('&response_type=code'), + contains('&client_id=identifier'), + contains('&redirect_uri=http%3A%2F%2Fexample.com%2Fredirect'), + matches(r'&code_challenge=[A-Za-z0-9\+\/\-\_]{43}'), + contains('&code_challenge_method=S256'), + ])); + }); + + test("can't be called twice", () { + grant.getAuthorizationUrl(redirectUrl); + expect(() => grant.getAuthorizationUrl(redirectUrl), throwsStateError); + }); + }); + + group('.handleAuthorizationResponse', () { + test("can't be called before .getAuthorizationUrl", () { + expect(grant.handleAuthorizationResponse({}), throwsStateError); + }); + + test("can't be called twice", () { + grant.getAuthorizationUrl(redirectUrl); + expect(grant.handleAuthorizationResponse({'code': 'auth code'}), + throwsFormatException); + expect(grant.handleAuthorizationResponse({'code': 'auth code'}), + throwsStateError); + }); + + test('must have a state parameter if the authorization URL did', () { + grant.getAuthorizationUrl(redirectUrl, state: 'state'); + expect(grant.handleAuthorizationResponse({'code': 'auth code'}), + throwsFormatException); + }); + + test('must have the same state parameter the authorization URL did', () { + grant.getAuthorizationUrl(redirectUrl, state: 'state'); + expect( + grant.handleAuthorizationResponse( + {'code': 'auth code', 'state': 'other state'}), + throwsFormatException); + }); + + test('must have a code parameter', () { + grant.getAuthorizationUrl(redirectUrl); + expect(grant.handleAuthorizationResponse({}), throwsFormatException); + }); + + test('with an error parameter throws an AuthorizationException', () { + grant.getAuthorizationUrl(redirectUrl); + expect(grant.handleAuthorizationResponse({'error': 'invalid_request'}), + throwsA(isA())); + }); + + test('sends an authorization code request', () { + grant.getAuthorizationUrl(redirectUrl); + client.expectRequest((request) { + expect(request.method, equals('POST')); + expect(request.url.toString(), equals(grant.tokenEndpoint.toString())); + expect( + request.bodyFields, + allOf([ + containsPair('grant_type', 'authorization_code'), + containsPair('code', 'auth code'), + containsPair('redirect_uri', redirectUrl.toString()), + containsPair( + 'code_verifier', matches(r'[A-Za-z0-9\-\.\_\~]{128}')) + ])); + expect(request.headers, + containsPair('Authorization', 'Basic aWRlbnRpZmllcjpzZWNyZXQ=')); + + return Future.value(http.Response( + jsonEncode({ + 'access_token': 'access token', + 'token_type': 'bearer', + }), + 200, + headers: {'content-type': 'application/json'})); + }); + + expect( + grant.handleAuthorizationResponse({'code': 'auth code'}).then( + (client) => client.credentials.accessToken), + completion(equals('access token'))); + }); + }); + + group('.handleAuthorizationCode', () { + test("can't be called before .getAuthorizationUrl", () { + expect(grant.handleAuthorizationCode('auth code'), throwsStateError); + }); + + test("can't be called twice", () { + grant.getAuthorizationUrl(redirectUrl); + expect(grant.handleAuthorizationCode('auth code'), throwsFormatException); + expect(grant.handleAuthorizationCode('auth code'), throwsStateError); + }); + + test('sends an authorization code request', () async { + grant.getAuthorizationUrl(redirectUrl); + client.expectRequest((request) { + expect(request.method, equals('POST')); + expect(request.url.toString(), equals(grant.tokenEndpoint.toString())); + expect( + request.bodyFields, + allOf([ + containsPair('grant_type', 'authorization_code'), + containsPair('code', 'auth code'), + containsPair('redirect_uri', redirectUrl.toString()), + containsPair( + 'code_verifier', matches(r'[A-Za-z0-9\-\.\_\~]{128}')) + ])); + expect(request.headers, + containsPair('Authorization', 'Basic aWRlbnRpZmllcjpzZWNyZXQ=')); + + return Future.value(http.Response( + jsonEncode({ + 'access_token': 'access token', + 'token_type': 'bearer', + }), + 200, + headers: {'content-type': 'application/json'})); + }); + + expect( + await grant.handleAuthorizationCode('auth code'), + isA().having((c) => c.credentials.accessToken, + 'credentials.accessToken', 'access token')); + }); + }); + + group('with basicAuth: false', () { + setUp(() { + client = ExpectClient(); + grant = oauth2.AuthorizationCodeGrant( + 'identifier', + Uri.parse('https://example.com/authorization'), + Uri.parse('https://example.com/token'), + secret: 'secret', + basicAuth: false, + httpClient: client); + }); + + test('.handleAuthorizationResponse sends an authorization code request', + () { + grant.getAuthorizationUrl(redirectUrl); + client.expectRequest((request) { + expect(request.method, equals('POST')); + expect(request.url.toString(), equals(grant.tokenEndpoint.toString())); + expect( + request.bodyFields, + allOf([ + containsPair('grant_type', 'authorization_code'), + containsPair('code', 'auth code'), + containsPair('redirect_uri', redirectUrl.toString()), + containsPair( + 'code_verifier', matches(r'[A-Za-z0-9\-\.\_\~]{128}')), + containsPair('client_id', 'identifier'), + containsPair('client_secret', 'secret') + ])); + + return Future.value(http.Response( + jsonEncode({ + 'access_token': 'access token', + 'token_type': 'bearer', + }), + 200, + headers: {'content-type': 'application/json'})); + }); + + expect( + grant.handleAuthorizationResponse({'code': 'auth code'}).then( + (client) => client.credentials.accessToken), + completion(equals('access token'))); + }); + + test('.handleAuthorizationCode sends an authorization code request', + () async { + grant.getAuthorizationUrl(redirectUrl); + client.expectRequest((request) { + expect(request.method, equals('POST')); + expect(request.url.toString(), equals(grant.tokenEndpoint.toString())); + expect( + request.bodyFields, + allOf([ + containsPair('grant_type', 'authorization_code'), + containsPair('code', 'auth code'), + containsPair('redirect_uri', redirectUrl.toString()), + containsPair( + 'code_verifier', matches(r'[A-Za-z0-9\-\.\_\~]{128}')), + containsPair('client_id', 'identifier'), + containsPair('client_secret', 'secret') + ])); + + return Future.value(http.Response( + jsonEncode({ + 'access_token': 'access token', + 'token_type': 'bearer', + }), + 200, + headers: {'content-type': 'application/json'})); + }); + + expect( + await grant.handleAuthorizationCode('auth code'), + isA().having((c) => c.credentials.accessToken, + 'credentials.accessToken', 'access token')); + }); + }); + + group('onCredentialsRefreshed', () { + test('is correctly propagated', () async { + var isCallbackInvoked = false; + var grant = oauth2.AuthorizationCodeGrant( + 'identifier', + Uri.parse('https://example.com/authorization'), + Uri.parse('https://example.com/token'), + secret: 'secret', + basicAuth: false, + httpClient: client, onCredentialsRefreshed: (credentials) { + isCallbackInvoked = true; + }); + + grant.getAuthorizationUrl(redirectUrl); + client.expectRequest( + (request) => Future.value( + http.Response( + jsonEncode({ + 'access_token': 'access token', + 'token_type': 'bearer', + 'expires_in': -3600, + 'refresh_token': 'refresh token', + }), + 200, + headers: {'content-type': 'application/json'}, + ), + ), + ); + + var oauth2Client = await grant.handleAuthorizationCode('auth code'); + + client.expectRequest( + (request) => Future.value( + http.Response( + jsonEncode({ + 'access_token': 'new access token', + 'token_type': 'bearer', + }), + 200, + headers: {'content-type': 'application/json'}, + ), + ), + ); + + client.expectRequest( + (request) => Future.value(http.Response('good job', 200))); + + await oauth2Client.read(Uri.parse('http://example.com/resource')); + + expect(isCallbackInvoked, equals(true)); + }); + }); +} diff --git a/pkgs/oauth2/test/client_credentials_grant_test.dart b/pkgs/oauth2/test/client_credentials_grant_test.dart new file mode 100644 index 000000000..28de4253e --- /dev/null +++ b/pkgs/oauth2/test/client_credentials_grant_test.dart @@ -0,0 +1,113 @@ +// Copyright (c) 2012, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +@TestOn('vm') +library; + +import 'dart:convert'; + +import 'package:http/http.dart' as http; +import 'package:oauth2/oauth2.dart' as oauth2; +import 'package:test/test.dart'; + +import 'utils.dart'; + +final success = jsonEncode({ + 'access_token': '2YotnFZFEjr1zCsicMWpAA', + 'token_type': 'bearer', + 'expires_in': 3600, + 'refresh_token': 'tGzv3JOkF0XG5Qx2TlKWIA', +}); + +String auth = 'Basic Y2xpZW50OnNlY3JldA=='; +Uri authEndpoint = Uri.parse('https://example.com'); + +void main() { + late ExpectClient expectClient; + + setUp(() => expectClient = ExpectClient()); + + group('basic', () { + test('builds correct request with client when using basic auth for client', + () async { + expectClient.expectRequest((request) async { + expect(auth, equals(request.headers['authorization'])); + expect(request.bodyFields['grant_type'], equals('client_credentials')); + return http.Response(success, 200, + headers: {'content-type': 'application/json'}); + }); + + var client = await oauth2.clientCredentialsGrant( + authEndpoint, 'client', 'secret', + httpClient: expectClient); + + expect(client.credentials, isNotNull); + expect(client.credentials.accessToken, equals('2YotnFZFEjr1zCsicMWpAA')); + }); + + test('builds correct request when using query parameters for client', + () async { + expectClient.expectRequest((request) async { + expect(request.bodyFields['grant_type'], equals('client_credentials')); + expect(request.bodyFields['client_id'], equals('client')); + expect(request.bodyFields['client_secret'], equals('secret')); + return http.Response(success, 200, + headers: {'content-type': 'application/json'}); + }); + + var client = await oauth2.clientCredentialsGrant( + authEndpoint, 'client', 'secret', + basicAuth: false, httpClient: expectClient); + expect(client.credentials, isNotNull); + expect(client.credentials.accessToken, equals('2YotnFZFEjr1zCsicMWpAA')); + }); + + test('builds correct request using scope', () async { + expectClient.expectRequest((request) async { + expect(auth, equals(request.headers['authorization'])); + expect(request.bodyFields['grant_type'], equals('client_credentials')); + expect(request.bodyFields['scope'], equals('one two')); + return http.Response(success, 200, + headers: {'content-type': 'application/json'}); + }); + + var client = await oauth2.clientCredentialsGrant( + authEndpoint, 'client', 'secret', + scopes: ['one', 'two'], httpClient: expectClient); + expect(client.credentials, isNotNull); + expect(client.credentials.accessToken, equals('2YotnFZFEjr1zCsicMWpAA')); + }); + + test('builds correct request using scope with custom delimiter', () async { + expectClient.expectRequest((request) async { + expect(request.bodyFields['grant_type'], equals('client_credentials')); + expect(request.bodyFields['scope'], equals('one,two')); + return http.Response(success, 200, + headers: {'content-type': 'application/json'}); + }); + + await oauth2.clientCredentialsGrant(authEndpoint, 'client', 'secret', + scopes: ['one', 'two'], httpClient: expectClient, delimiter: ','); + }); + + test('merges with existing query parameters', () async { + var authEndpoint = Uri.parse('https://example.com?query=value'); + + expectClient.expectRequest((request) async { + expect(request.bodyFields['grant_type'], equals('client_credentials')); + expect(request.bodyFields['client_id'], equals('client')); + expect(request.bodyFields['client_secret'], equals('secret')); + expect(request.url.queryParameters['query'], equals('value')); + return http.Response(success, 200, + headers: {'content-type': 'application/json'}); + }); + + var client = await oauth2.clientCredentialsGrant( + authEndpoint, 'client', 'secret', + basicAuth: false, httpClient: expectClient); + expect(client.credentials, isNotNull); + expect(client.credentials.accessToken, equals('2YotnFZFEjr1zCsicMWpAA')); + }); + }); +} diff --git a/pkgs/oauth2/test/client_test.dart b/pkgs/oauth2/test/client_test.dart new file mode 100644 index 000000000..3c30d36c8 --- /dev/null +++ b/pkgs/oauth2/test/client_test.dart @@ -0,0 +1,287 @@ +// Copyright (c) 2012, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'dart:async'; +import 'dart:convert'; + +import 'package:http/http.dart' as http; +import 'package:oauth2/oauth2.dart' as oauth2; +import 'package:test/test.dart'; + +import 'utils.dart'; + +final Uri requestUri = Uri.parse('http://example.com/resource'); + +final Uri tokenEndpoint = Uri.parse('http://example.com/token'); + +void main() { + late ExpectClient httpClient; + + setUp(() => httpClient = ExpectClient()); + + group('with expired credentials', () { + test("that can't be refreshed throws an ExpirationException on send", () { + var expiration = DateTime.now().subtract(const Duration(hours: 1)); + var credentials = + oauth2.Credentials('access token', expiration: expiration); + var client = oauth2.Client(credentials, + identifier: 'identifier', secret: 'secret', httpClient: httpClient); + + expect(client.get(requestUri), + throwsA(const TypeMatcher())); + }); + + test( + 'that can be refreshed refreshes the credentials and sends the ' + 'request', () async { + var expiration = DateTime.now().subtract(const Duration(hours: 1)); + var credentials = oauth2.Credentials('access token', + refreshToken: 'refresh token', + tokenEndpoint: tokenEndpoint, + expiration: expiration); + var client = oauth2.Client(credentials, + identifier: 'identifier', secret: 'secret', httpClient: httpClient); + + httpClient.expectRequest((request) { + expect(request.method, equals('POST')); + expect(request.url.toString(), equals(tokenEndpoint.toString())); + return Future.value(http.Response( + jsonEncode( + {'access_token': 'new access token', 'token_type': 'bearer'}), + 200, + headers: {'content-type': 'application/json'})); + }); + + httpClient.expectRequest((request) { + expect(request.method, equals('GET')); + expect(request.url.toString(), equals(requestUri.toString())); + expect(request.headers['authorization'], + equals('Bearer new access token')); + + return Future.value(http.Response('good job', 200)); + }); + + await client.read(requestUri); + expect(client.credentials.accessToken, equals('new access token')); + }); + + test( + 'that can be refreshed refreshes only once if multiple requests are made', + () async { + var expiration = DateTime.now().subtract(const Duration(hours: 1)); + var credentials = oauth2.Credentials('access token', + refreshToken: 'refresh token', + tokenEndpoint: tokenEndpoint, + expiration: expiration); + var client = oauth2.Client(credentials, + identifier: 'identifier', secret: 'secret', httpClient: httpClient); + + httpClient.expectRequest((request) { + expect(request.method, equals('POST')); + expect(request.url.toString(), equals(tokenEndpoint.toString())); + return Future.value(http.Response( + jsonEncode( + {'access_token': 'new access token', 'token_type': 'bearer'}), + 200, + headers: {'content-type': 'application/json'})); + }); + + const numCalls = 2; + + for (var i = 0; i < numCalls; i++) { + httpClient.expectRequest((request) { + expect(request.method, equals('GET')); + expect(request.url.toString(), equals(requestUri.toString())); + expect(request.headers['authorization'], + equals('Bearer new access token')); + + return Future.value(http.Response('good job', 200)); + }); + } + + await Future.wait( + List>.generate( + numCalls, (_) => client.read(requestUri)), + ); + + expect(client.credentials.accessToken, equals('new access token')); + }, + ); + + test('that onCredentialsRefreshed is called', () async { + var callbackCalled = false; + + var expiration = DateTime.now().subtract(const Duration(hours: 1)); + var credentials = oauth2.Credentials('access token', + refreshToken: 'refresh token', + tokenEndpoint: tokenEndpoint, + expiration: expiration); + var client = oauth2.Client(credentials, + identifier: 'identifier', + secret: 'secret', + httpClient: httpClient, onCredentialsRefreshed: (credentials) { + callbackCalled = true; + expect(credentials.accessToken, equals('new access token')); + }); + + httpClient.expectRequest( + (request) => Future.value( + http.Response( + jsonEncode( + {'access_token': 'new access token', 'token_type': 'bearer'}, + ), + 200, + headers: {'content-type': 'application/json'}, + ), + ), + ); + + httpClient.expectRequest( + (request) => Future.value(http.Response('good job', 200))); + + await client.read(requestUri); + expect(callbackCalled, equals(true)); + }); + }); + + group('with valid credentials', () { + test('sends a request with bearer authorization', () { + var credentials = oauth2.Credentials('access token'); + var client = oauth2.Client(credentials, + identifier: 'identifier', secret: 'secret', httpClient: httpClient); + + httpClient.expectRequest((request) { + expect(request.method, equals('GET')); + expect(request.url.toString(), equals(requestUri.toString())); + expect(request.headers['authorization'], equals('Bearer access token')); + + return Future.value(http.Response('good job', 200)); + }); + + expect(client.read(requestUri), completion(equals('good job'))); + }); + + test('can manually refresh the credentials', () async { + var credentials = oauth2.Credentials('access token', + refreshToken: 'refresh token', tokenEndpoint: tokenEndpoint); + var client = oauth2.Client(credentials, + identifier: 'identifier', secret: 'secret', httpClient: httpClient); + + httpClient.expectRequest((request) { + expect(request.method, equals('POST')); + expect(request.url.toString(), equals(tokenEndpoint.toString())); + return Future.value(http.Response( + jsonEncode( + {'access_token': 'new access token', 'token_type': 'bearer'}), + 200, + headers: {'content-type': 'application/json'})); + }); + + await client.refreshCredentials(); + expect(client.credentials.accessToken, equals('new access token')); + }); + + test("without a refresh token can't manually refresh the credentials", () { + var credentials = oauth2.Credentials('access token'); + var client = oauth2.Client(credentials, + identifier: 'identifier', secret: 'secret', httpClient: httpClient); + + expect(client.refreshCredentials(), throwsA(isStateError)); + }); + }); + + group('with invalid credentials', () { + test('throws an AuthorizationException for a 401 response', () { + var credentials = oauth2.Credentials('access token'); + var client = oauth2.Client(credentials, + identifier: 'identifier', secret: 'secret', httpClient: httpClient); + + httpClient.expectRequest((request) { + expect(request.method, equals('GET')); + expect(request.url.toString(), equals(requestUri.toString())); + expect(request.headers['authorization'], equals('Bearer access token')); + + var authenticate = 'Bearer error="invalid_token", error_description=' + '"Something is terribly wrong."'; + return Future.value(http.Response('bad job', 401, + headers: {'www-authenticate': authenticate})); + }); + + expect(client.read(requestUri), + throwsA(const TypeMatcher())); + }); + + test('passes through a 401 response without www-authenticate', () async { + var credentials = oauth2.Credentials('access token'); + var client = oauth2.Client(credentials, + identifier: 'identifier', secret: 'secret', httpClient: httpClient); + + httpClient.expectRequest((request) { + expect(request.method, equals('GET')); + expect(request.url.toString(), equals(requestUri.toString())); + expect(request.headers['authorization'], equals('Bearer access token')); + + return Future.value(http.Response('bad job', 401)); + }); + + expect((await client.get(requestUri)).statusCode, equals(401)); + }); + + test('passes through a 401 response with invalid www-authenticate', + () async { + var credentials = oauth2.Credentials('access token'); + var client = oauth2.Client(credentials, + identifier: 'identifier', secret: 'secret', httpClient: httpClient); + + httpClient.expectRequest((request) { + expect(request.method, equals('GET')); + expect(request.url.toString(), equals(requestUri.toString())); + expect(request.headers['authorization'], equals('Bearer access token')); + + var authenticate = 'Bearer error="invalid_token" error_description=' + '"Something is terribly wrong."'; + return Future.value(http.Response('bad job', 401, + headers: {'www-authenticate': authenticate})); + }); + + expect((await client.get(requestUri)).statusCode, equals(401)); + }); + + test('passes through a 401 response with non-bearer www-authenticate', + () async { + var credentials = oauth2.Credentials('access token'); + var client = oauth2.Client(credentials, + identifier: 'identifier', secret: 'secret', httpClient: httpClient); + + httpClient.expectRequest((request) { + expect(request.method, equals('GET')); + expect(request.url.toString(), equals(requestUri.toString())); + expect(request.headers['authorization'], equals('Bearer access token')); + + return Future.value(http.Response('bad job', 401, + headers: {'www-authenticate': 'Digest'})); + }); + + expect((await client.get(requestUri)).statusCode, equals(401)); + }); + + test('passes through a 401 response with non-OAuth2 www-authenticate', + () async { + var credentials = oauth2.Credentials('access token'); + var client = oauth2.Client(credentials, + identifier: 'identifier', secret: 'secret', httpClient: httpClient); + + httpClient.expectRequest((request) { + expect(request.method, equals('GET')); + expect(request.url.toString(), equals(requestUri.toString())); + expect(request.headers['authorization'], equals('Bearer access token')); + + return Future.value(http.Response('bad job', 401, + headers: {'www-authenticate': 'Bearer'})); + }); + + expect((await client.get(requestUri)).statusCode, equals(401)); + }); + }); +} diff --git a/pkgs/oauth2/test/credentials_test.dart b/pkgs/oauth2/test/credentials_test.dart new file mode 100644 index 000000000..d83bc7e6d --- /dev/null +++ b/pkgs/oauth2/test/credentials_test.dart @@ -0,0 +1,331 @@ +// Copyright (c) 2012, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'dart:async'; +import 'dart:convert'; + +import 'package:http/http.dart' as http; +import 'package:oauth2/oauth2.dart' as oauth2; +import 'package:test/test.dart'; + +import 'utils.dart'; + +final Uri tokenEndpoint = Uri.parse('http://example.com/token'); + +void main() { + late ExpectClient httpClient; + + setUp(() => httpClient = ExpectClient()); + + test('is not expired if no expiration exists', () { + var credentials = oauth2.Credentials('access token'); + expect(credentials.isExpired, isFalse); + }); + + test('is not expired if the expiration is in the future', () { + var expiration = DateTime.now().add(const Duration(hours: 1)); + var credentials = + oauth2.Credentials('access token', expiration: expiration); + expect(credentials.isExpired, isFalse); + }); + + test('is expired if the expiration is in the past', () { + var expiration = DateTime.now().subtract(const Duration(hours: 1)); + var credentials = + oauth2.Credentials('access token', expiration: expiration); + expect(credentials.isExpired, isTrue); + }); + + test("can't refresh without a refresh token", () { + var credentials = + oauth2.Credentials('access token', tokenEndpoint: tokenEndpoint); + expect(credentials.canRefresh, false); + + expect( + credentials.refresh( + identifier: 'identifier', secret: 'secret', httpClient: httpClient), + throwsStateError); + }); + + test("can't refresh without a token endpoint", () { + var credentials = + oauth2.Credentials('access token', refreshToken: 'refresh token'); + expect(credentials.canRefresh, false); + + expect( + credentials.refresh( + identifier: 'identifier', secret: 'secret', httpClient: httpClient), + throwsStateError); + }); + + test('can refresh with a refresh token and a token endpoint', () async { + var credentials = oauth2.Credentials('access token', + refreshToken: 'refresh token', + tokenEndpoint: tokenEndpoint, + scopes: ['scope1', 'scope2']); + expect(credentials.canRefresh, true); + + httpClient.expectRequest((request) { + expect(request.method, equals('POST')); + expect(request.url.toString(), equals(tokenEndpoint.toString())); + expect( + request.bodyFields, + equals({ + 'grant_type': 'refresh_token', + 'refresh_token': 'refresh token', + 'scope': 'scope1 scope2' + })); + expect( + request.headers, + containsPair('Authorization', + 'Basic aWQlQzMlQUJudCVDNCVBQmZpZXI6cyVDMyVBQmNyZXQ=')); + + return Future.value(http.Response( + jsonEncode({ + 'access_token': 'new access token', + 'token_type': 'bearer', + 'refresh_token': 'new refresh token' + }), + 200, + headers: {'content-type': 'application/json'})); + }); + + credentials = await credentials.refresh( + identifier: 'idëntīfier', secret: 'sëcret', httpClient: httpClient); + expect(credentials.accessToken, equals('new access token')); + expect(credentials.refreshToken, equals('new refresh token')); + }); + + test('sets proper scope string when using custom delimiter', () async { + var credentials = oauth2.Credentials('access token', + refreshToken: 'refresh token', + tokenEndpoint: tokenEndpoint, + scopes: ['scope1', 'scope2'], + delimiter: ','); + httpClient.expectRequest((http.Request request) { + expect(request.bodyFields['scope'], equals('scope1,scope2')); + return Future.value(http.Response( + jsonEncode({ + 'access_token': 'new access token', + 'token_type': 'bearer', + 'refresh_token': 'new refresh token' + }), + 200, + headers: {'content-type': 'application/json'})); + }); + await credentials.refresh( + identifier: 'idëntīfier', secret: 'sëcret', httpClient: httpClient); + }); + + test('can refresh without a client secret', () async { + var credentials = oauth2.Credentials('access token', + refreshToken: 'refresh token', + tokenEndpoint: tokenEndpoint, + scopes: ['scope1', 'scope2']); + expect(credentials.canRefresh, true); + + httpClient.expectRequest((request) { + expect(request.method, equals('POST')); + expect(request.url.toString(), equals(tokenEndpoint.toString())); + expect( + request.bodyFields, + equals({ + 'grant_type': 'refresh_token', + 'refresh_token': 'refresh token', + 'scope': 'scope1 scope2', + 'client_id': 'identifier' + })); + + return Future.value(http.Response( + jsonEncode({ + 'access_token': 'new access token', + 'token_type': 'bearer', + 'refresh_token': 'new refresh token' + }), + 200, + headers: {'content-type': 'application/json'})); + }); + + credentials = await credentials.refresh( + identifier: 'identifier', httpClient: httpClient); + expect(credentials.accessToken, equals('new access token')); + expect(credentials.refreshToken, equals('new refresh token')); + }); + + test('can refresh without client authentication', () async { + var credentials = oauth2.Credentials('access token', + refreshToken: 'refresh token', + tokenEndpoint: tokenEndpoint, + scopes: ['scope1', 'scope2']); + expect(credentials.canRefresh, true); + + httpClient.expectRequest((request) { + expect(request.method, equals('POST')); + expect(request.url.toString(), equals(tokenEndpoint.toString())); + expect( + request.bodyFields, + equals({ + 'grant_type': 'refresh_token', + 'refresh_token': 'refresh token', + 'scope': 'scope1 scope2' + })); + + return Future.value(http.Response( + jsonEncode({ + 'access_token': 'new access token', + 'token_type': 'bearer', + 'refresh_token': 'new refresh token' + }), + 200, + headers: {'content-type': 'application/json'})); + }); + + credentials = await credentials.refresh(httpClient: httpClient); + expect(credentials.accessToken, equals('new access token')); + expect(credentials.refreshToken, equals('new refresh token')); + }); + + test("uses the old refresh token if a new one isn't provided", () async { + var credentials = oauth2.Credentials('access token', + refreshToken: 'refresh token', tokenEndpoint: tokenEndpoint); + expect(credentials.canRefresh, true); + + httpClient.expectRequest((request) { + expect(request.method, equals('POST')); + expect(request.url.toString(), equals(tokenEndpoint.toString())); + expect( + request.bodyFields, + equals({ + 'grant_type': 'refresh_token', + 'refresh_token': 'refresh token' + })); + expect( + request.headers, + containsPair('Authorization', + 'Basic aWQlQzMlQUJudCVDNCVBQmZpZXI6cyVDMyVBQmNyZXQ=')); + + return Future.value(http.Response( + jsonEncode( + {'access_token': 'new access token', 'token_type': 'bearer'}), + 200, + headers: {'content-type': 'application/json'})); + }); + + credentials = await credentials.refresh( + identifier: 'idëntīfier', secret: 'sëcret', httpClient: httpClient); + expect(credentials.accessToken, equals('new access token')); + expect(credentials.refreshToken, equals('refresh token')); + }); + + test('uses form-field authentication if basicAuth is false', () async { + var credentials = oauth2.Credentials('access token', + refreshToken: 'refresh token', + tokenEndpoint: tokenEndpoint, + scopes: ['scope1', 'scope2']); + expect(credentials.canRefresh, true); + + httpClient.expectRequest((request) { + expect(request.method, equals('POST')); + expect(request.url.toString(), equals(tokenEndpoint.toString())); + expect( + request.bodyFields, + equals({ + 'grant_type': 'refresh_token', + 'refresh_token': 'refresh token', + 'scope': 'scope1 scope2', + 'client_id': 'idëntīfier', + 'client_secret': 'sëcret' + })); + + return Future.value(http.Response( + jsonEncode({ + 'access_token': 'new access token', + 'token_type': 'bearer', + 'refresh_token': 'new refresh token' + }), + 200, + headers: {'content-type': 'application/json'})); + }); + + credentials = await credentials.refresh( + identifier: 'idëntīfier', + secret: 'sëcret', + basicAuth: false, + httpClient: httpClient); + expect(credentials.accessToken, equals('new access token')); + expect(credentials.refreshToken, equals('new refresh token')); + }); + + group('fromJson', () { + oauth2.Credentials fromMap(Map map) => + oauth2.Credentials.fromJson(jsonEncode(map)); + + test('should load the same credentials from toJson', () { + // Round the expiration down to milliseconds since epoch, since that's + // what the credentials file stores. Otherwise sub-millisecond time gets + // in the way. + var expiration = DateTime.now().subtract(const Duration(hours: 1)); + expiration = DateTime.fromMillisecondsSinceEpoch( + expiration.millisecondsSinceEpoch); + + var credentials = oauth2.Credentials('access token', + refreshToken: 'refresh token', + idToken: 'id token', + tokenEndpoint: tokenEndpoint, + scopes: ['scope1', 'scope2'], + expiration: expiration); + var reloaded = oauth2.Credentials.fromJson(credentials.toJson()); + + expect(reloaded.accessToken, equals(credentials.accessToken)); + expect(reloaded.refreshToken, equals(credentials.refreshToken)); + expect(reloaded.idToken, equals(credentials.idToken)); + expect(reloaded.tokenEndpoint.toString(), + equals(credentials.tokenEndpoint.toString())); + expect(reloaded.scopes, equals(credentials.scopes)); + expect(reloaded.expiration, equals(credentials.expiration)); + }); + + test('should throw a FormatException for invalid JSON', () { + expect( + () => oauth2.Credentials.fromJson('foo bar'), throwsFormatException); + }); + + test("should throw a FormatException for JSON that's not a map", () { + expect(() => oauth2.Credentials.fromJson('null'), throwsFormatException); + }); + + test('should throw a FormatException if there is no accessToken', () { + expect(() => fromMap({}), throwsFormatException); + }); + + test('should throw a FormatException if accessToken is not a string', () { + expect(() => fromMap({'accessToken': 12}), throwsFormatException); + }); + + test('should throw a FormatException if refreshToken is not a string', () { + expect(() => fromMap({'accessToken': 'foo', 'refreshToken': 12}), + throwsFormatException); + }); + + test('should throw a FormatException if idToken is not a string', () { + expect(() => fromMap({'accessToken': 'foo', 'idToken': 12}), + throwsFormatException); + }); + + test('should throw a FormatException if tokenEndpoint is not a string', () { + expect(() => fromMap({'accessToken': 'foo', 'tokenEndpoint': 12}), + throwsFormatException); + }); + + test('should throw a FormatException if scopes is not a list', () { + expect(() => fromMap({'accessToken': 'foo', 'scopes': 12}), + throwsFormatException); + }); + + test('should throw a FormatException if expiration is not an int', () { + expect(() => fromMap({'accessToken': 'foo', 'expiration': '12'}), + throwsFormatException); + }); + }); +} diff --git a/pkgs/oauth2/test/handle_access_token_response_test.dart b/pkgs/oauth2/test/handle_access_token_response_test.dart new file mode 100644 index 000000000..4d7b5199e --- /dev/null +++ b/pkgs/oauth2/test/handle_access_token_response_test.dart @@ -0,0 +1,301 @@ +// Copyright (c) 2012, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'dart:convert'; + +import 'package:http/http.dart' as http; +import 'package:oauth2/oauth2.dart' as oauth2; +import 'package:oauth2/src/handle_access_token_response.dart'; +import 'package:oauth2/src/parameters.dart'; +import 'package:test/test.dart'; + +import 'utils.dart'; + +final Uri tokenEndpoint = Uri.parse('https://example.com/token'); + +final DateTime startTime = DateTime.now(); + +oauth2.Credentials handle(http.Response response, + {GetParameters? getParameters}) => + handleAccessTokenResponse( + response, tokenEndpoint, startTime, ['scope'], ' ', + getParameters: getParameters); + +void main() { + group('an error response', () { + oauth2.Credentials handleError( + {String body = '{"error": "invalid_request"}', + int statusCode = 400, + Map headers = const { + 'content-type': 'application/json' + }}) => + handle(http.Response(body, statusCode, headers: headers)); + + test('causes an AuthorizationException', () { + expect(handleError, throwsAuthorizationException); + }); + + test('with a 401 code causes an AuthorizationException', () { + expect(() => handleError(statusCode: 401), throwsAuthorizationException); + }); + + test('with an unexpected code causes a FormatException', () { + expect(() => handleError(statusCode: 500), throwsFormatException); + }); + + test('with no content-type causes a FormatException', () { + expect(() => handleError(headers: {}), throwsFormatException); + }); + + test('with a non-JSON content-type causes a FormatException', () { + expect(() => handleError(headers: {'content-type': 'text/plain'}), + throwsFormatException); + }); + + test('with a non-JSON, non-plain content-type causes a FormatException', + () { + expect(() => handleError(headers: {'content-type': 'image/png'}), + throwsFormatException); + }); + + test( + 'with a JSON content-type and charset causes an ' + 'AuthorizationException', () { + expect( + () => handleError( + headers: {'content-type': 'application/json; charset=UTF-8'}), + throwsAuthorizationException); + }); + + test('with invalid JSON causes a FormatException', () { + expect(() => handleError(body: 'not json'), throwsFormatException); + }); + + test('with a non-string error causes a FormatException', () { + expect(() => handleError(body: '{"error": 12}'), throwsFormatException); + }); + + test('with a non-string error_description causes a FormatException', () { + expect( + () => handleError( + body: jsonEncode( + {'error': 'invalid_request', 'error_description': 12})), + throwsFormatException); + }); + + test('with a non-string error_uri causes a FormatException', () { + expect( + () => handleError( + body: jsonEncode({'error': 'invalid_request', 'error_uri': 12})), + throwsFormatException); + }); + + test('with a string error_description causes a AuthorizationException', () { + expect( + () => handleError( + body: jsonEncode({ + 'error': 'invalid_request', + 'error_description': 'description' + })), + throwsAuthorizationException); + }); + + test('with a string error_uri causes a AuthorizationException', () { + expect( + () => handleError( + body: jsonEncode({ + 'error': 'invalid_request', + 'error_uri': 'http://example.com/error' + })), + throwsAuthorizationException); + }); + }); + + group('a success response', () { + oauth2.Credentials handleSuccess( + {String contentType = 'application/json', + Object? accessToken = 'access token', + Object? tokenType = 'bearer', + Object? expiresIn, + Object? refreshToken, + Object? scope}) { + return handle(http.Response( + jsonEncode({ + 'access_token': accessToken, + 'token_type': tokenType, + 'expires_in': expiresIn, + 'refresh_token': refreshToken, + 'scope': scope + }), + 200, + headers: {'content-type': contentType})); + } + + test('returns the correct credentials', () { + var credentials = handleSuccess(); + expect(credentials.accessToken, equals('access token')); + expect(credentials.tokenEndpoint.toString(), + equals(tokenEndpoint.toString())); + }); + + test('with no content-type causes a FormatException', () { + expect(() => handleSuccess(contentType: ''), throwsFormatException); + }); + + test('with a non-JSON content-type causes a FormatException', () { + expect(() => handleSuccess(contentType: 'text/plain'), + throwsFormatException); + }); + + test( + 'with a JSON content-type and charset returns the correct ' + 'credentials', () { + var credentials = + handleSuccess(contentType: 'application/json; charset=UTF-8'); + expect(credentials.accessToken, equals('access token')); + }); + + test('with a JavScript content-type returns the correct credentials', () { + var credentials = handleSuccess(contentType: 'text/javascript'); + expect(credentials.accessToken, equals('access token')); + }); + + test('with custom getParameters() returns the correct credentials', () { + var body = '_${jsonEncode({ + 'token_type': 'bearer', + 'access_token': 'access token' + })}'; + var credentials = handle( + http.Response(body, 200, headers: {'content-type': 'text/plain'}), + getParameters: (contentType, body) => + jsonDecode(body.substring(1)) as Map, + ); + expect(credentials.accessToken, equals('access token')); + expect(credentials.tokenEndpoint.toString(), + equals(tokenEndpoint.toString())); + }); + + test('throws a FormatException if custom getParameters rejects response', + () { + var response = http.Response( + jsonEncode({ + 'access_token': 'access token', + 'token_type': 'bearer', + 'expires_in': 24, + 'refresh_token': 'refresh token', + 'scope': 'scope', + }), + 200, + headers: {'content-type': 'foo/bar'}); + + expect( + () => handle(response, + getParameters: (contentType, body) => throw FormatException( + 'unsupported content-type: $contentType')), + throwsFormatException); + }); + + test('with a null access token throws a FormatException', () { + expect(() => handleSuccess(accessToken: null), throwsFormatException); + }); + + test('with a non-string access token throws a FormatException', () { + expect(() => handleSuccess(accessToken: 12), throwsFormatException); + }); + + test('with a null token type throws a FormatException', () { + expect(() => handleSuccess(tokenType: null), throwsFormatException); + }); + + test('with a non-string token type throws a FormatException', () { + expect(() => handleSuccess(tokenType: 12), throwsFormatException); + }); + + test('with a non-"bearer" token type throws a FormatException', () { + expect(() => handleSuccess(tokenType: 'mac'), throwsFormatException); + }); + + test('with a non-int expires-in throws a FormatException', () { + expect(() => handleSuccess(expiresIn: 'whenever'), throwsFormatException); + }); + + test( + 'with expires-in sets the expiration to ten seconds earlier than the ' + 'server says', () { + var credentials = handleSuccess(expiresIn: 100); + expect(credentials.expiration?.millisecondsSinceEpoch, + startTime.millisecondsSinceEpoch + 90 * 1000); + }); + + test('with expires-in encoded as string', () { + var credentials = handleSuccess(expiresIn: '110'); + expect(credentials.expiration?.millisecondsSinceEpoch, + startTime.millisecondsSinceEpoch + 100 * 1000); + }); + + test('with a non-string refresh token throws a FormatException', () { + expect(() => handleSuccess(refreshToken: 12), throwsFormatException); + }); + + test('with a refresh token sets the refresh token', () { + var credentials = handleSuccess(refreshToken: 'refresh me'); + expect(credentials.refreshToken, equals('refresh me')); + }); + + test('with a non-string scope throws a FormatException', () { + expect(() => handleSuccess(scope: 12), throwsFormatException); + }); + + test('with a scope sets the scopes', () { + var credentials = handleSuccess(scope: 'scope1 scope2'); + expect(credentials.scopes, equals(['scope1', 'scope2'])); + }); + + test('with a custom scope delimiter sets the scopes', () { + var response = http.Response( + jsonEncode({ + 'access_token': 'access token', + 'token_type': 'bearer', + 'expires_in': null, + 'refresh_token': null, + 'scope': 'scope1,scope2' + }), + 200, + headers: {'content-type': 'application/json'}); + var credentials = handleAccessTokenResponse( + response, tokenEndpoint, startTime, ['scope'], ','); + expect(credentials.scopes, equals(['scope1', 'scope2'])); + }); + }); + + group('a success response with a id_token', () { + oauth2.Credentials handleSuccess( + {String contentType = 'application/json', + Object? accessToken = 'access token', + Object? tokenType = 'bearer', + Object? expiresIn, + Object? idToken = 'decode me', + Object? scope}) { + return handle(http.Response( + jsonEncode({ + 'access_token': accessToken, + 'token_type': tokenType, + 'expires_in': expiresIn, + 'id_token': idToken, + 'scope': scope + }), + 200, + headers: {'content-type': contentType})); + } + + test('with a non-string id token throws a FormatException', () { + expect(() => handleSuccess(idToken: 12), throwsFormatException); + }); + + test('with a id token sets the id token', () { + var credentials = handleSuccess(idToken: 'decode me'); + expect(credentials.idToken, equals('decode me')); + }); + }); +} diff --git a/pkgs/oauth2/test/resource_owner_password_grant_test.dart b/pkgs/oauth2/test/resource_owner_password_grant_test.dart new file mode 100644 index 000000000..7a5d9b5a0 --- /dev/null +++ b/pkgs/oauth2/test/resource_owner_password_grant_test.dart @@ -0,0 +1,168 @@ +// Copyright (c) 2012, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +@TestOn('vm') +library; + +import 'dart:async'; +import 'dart:convert'; + +import 'package:http/http.dart' as http; +import 'package:oauth2/oauth2.dart' as oauth2; +import 'package:test/test.dart'; + +import 'utils.dart'; + +final success = jsonEncode({ + 'access_token': '2YotnFZFEjr1zCsicMWpAA', + 'token_type': 'bearer', + 'expires_in': 3600, + 'refresh_token': 'tGzv3JOkF0XG5Qx2TlKWIA', +}); + +String auth = 'Basic Y2xpZW50OnNlY3JldA=='; +Uri authEndpoint = Uri.parse('https://example.com'); + +void main() { + late ExpectClient expectClient; + + setUp(() => expectClient = ExpectClient()); + + group('basic', () { + test('builds correct request with client when using basic auth for client', + () async { + expectClient.expectRequest((request) async { + expect(auth, equals(request.headers['authorization'])); + expect(request.bodyFields['grant_type'], equals('password')); + expect(request.bodyFields['username'], equals('username')); + expect(request.bodyFields['password'], equals('userpass')); + return http.Response(success, 200, + headers: {'content-type': 'application/json'}); + }); + + var client = await oauth2.resourceOwnerPasswordGrant( + authEndpoint, 'username', 'userpass', + identifier: 'client', secret: 'secret', httpClient: expectClient); + + expect(client.credentials, isNotNull); + expect(client.credentials.accessToken, equals('2YotnFZFEjr1zCsicMWpAA')); + }); + + test('passes the onCredentialsRefreshed callback to the client', () async { + expectClient.expectRequest((request) async { + return http.Response( + jsonEncode({ + 'access_token': '2YotnFZFEjr1zCsicMWpAA', + 'token_type': 'bearer', + 'expires_in': -3600, + 'refresh_token': 'tGzv3JOkF0XG5Qx2TlKWIA', + }), + 200, + headers: {'content-type': 'application/json'}); + }); + + var isCallbackInvoked = false; + + var client = await oauth2.resourceOwnerPasswordGrant( + authEndpoint, 'username', 'userpass', + identifier: 'client', secret: 'secret', httpClient: expectClient, + onCredentialsRefreshed: (oauth2.Credentials credentials) { + isCallbackInvoked = true; + }); + + expectClient.expectRequest((request) { + return Future.value(http.Response( + jsonEncode( + {'access_token': 'new access token', 'token_type': 'bearer'}), + 200, + headers: {'content-type': 'application/json'})); + }); + + expectClient.expectRequest((request) { + return Future.value(http.Response('good job', 200)); + }); + + await client.read(Uri.parse('http://example.com/resource')); + expect(isCallbackInvoked, equals(true)); + }); + + test('builds correct request when using query parameters for client', + () async { + expectClient.expectRequest((request) async { + expect(request.bodyFields['grant_type'], equals('password')); + expect(request.bodyFields['client_id'], equals('client')); + expect(request.bodyFields['client_secret'], equals('secret')); + expect(request.bodyFields['username'], equals('username')); + expect(request.bodyFields['password'], equals('userpass')); + return http.Response(success, 200, + headers: {'content-type': 'application/json'}); + }); + + var client = await oauth2.resourceOwnerPasswordGrant( + authEndpoint, 'username', 'userpass', + identifier: 'client', + secret: 'secret', + basicAuth: false, + httpClient: expectClient); + expect(client.credentials, isNotNull); + expect(client.credentials.accessToken, equals('2YotnFZFEjr1zCsicMWpAA')); + }); + + test('builds correct request using scope', () async { + expectClient.expectRequest((request) async { + expect(request.bodyFields['grant_type'], equals('password')); + expect(request.bodyFields['username'], equals('username')); + expect(request.bodyFields['password'], equals('userpass')); + expect(request.bodyFields['scope'], equals('one two')); + return http.Response(success, 200, + headers: {'content-type': 'application/json'}); + }); + + var client = await oauth2.resourceOwnerPasswordGrant( + authEndpoint, 'username', 'userpass', + scopes: ['one', 'two'], httpClient: expectClient); + expect(client.credentials, isNotNull); + expect(client.credentials.accessToken, equals('2YotnFZFEjr1zCsicMWpAA')); + }); + + test('builds correct request using scope with custom delimiter', () async { + expectClient.expectRequest((request) async { + expect(request.bodyFields['grant_type'], equals('password')); + expect(request.bodyFields['username'], equals('username')); + expect(request.bodyFields['password'], equals('userpass')); + expect(request.bodyFields['scope'], equals('one,two')); + return http.Response(success, 200, + headers: {'content-type': 'application/json'}); + }); + + await oauth2.resourceOwnerPasswordGrant( + authEndpoint, 'username', 'userpass', + scopes: ['one', 'two'], httpClient: expectClient, delimiter: ','); + }); + + test('merges with existing query parameters', () async { + var authEndpoint = Uri.parse('https://example.com?query=value'); + + expectClient.expectRequest((request) async { + expect(request.bodyFields['grant_type'], equals('password')); + expect(request.bodyFields['client_id'], equals('client')); + expect(request.bodyFields['client_secret'], equals('secret')); + expect(request.bodyFields['username'], equals('username')); + expect(request.bodyFields['password'], equals('userpass')); + expect(request.url.queryParameters['query'], equals('value')); + return http.Response(success, 200, + headers: {'content-type': 'application/json'}); + }); + + var client = await oauth2.resourceOwnerPasswordGrant( + authEndpoint, 'username', 'userpass', + identifier: 'client', + secret: 'secret', + basicAuth: false, + httpClient: expectClient); + expect(client.credentials, isNotNull); + expect(client.credentials.accessToken, equals('2YotnFZFEjr1zCsicMWpAA')); + }); + }); +} diff --git a/pkgs/oauth2/test/utils.dart b/pkgs/oauth2/test/utils.dart new file mode 100644 index 000000000..4f3b74726 --- /dev/null +++ b/pkgs/oauth2/test/utils.dart @@ -0,0 +1,49 @@ +// Copyright (c) 2012, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'dart:async'; +import 'dart:collection' show Queue; + +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:oauth2/oauth2.dart' as oauth2; +import 'package:test/test.dart'; + +class ExpectClient extends MockClient { + final Queue _handlers; + + ExpectClient._(super.fn) : _handlers = Queue(); + + factory ExpectClient() { + late ExpectClient client; + client = ExpectClient._((request) => client._handleRequest(request)); + return client; + } + + void expectRequest(MockClientHandler fn) { + var completer = Completer(); + expect(completer.future, completes); + + _handlers.add((request) { + completer.complete(null); + return fn(request); + }); + } + + Future _handleRequest(http.Request request) { + if (_handlers.isEmpty) { + return Future.value(http.Response('not found', 404)); + } else { + return _handlers.removeFirst()(request); + } + } +} + +/// A matcher for functions that throw AuthorizationException. +final Matcher throwsAuthorizationException = + throwsA(const TypeMatcher()); + +/// A matcher for functions that throw ExpirationException. +final Matcher throwsExpirationException = + throwsA(const TypeMatcher());