diff --git a/doc/dev_guide/README.md b/doc/dev_guide/README.md index a8b272e69..24e8be20e 100644 --- a/doc/dev_guide/README.md +++ b/doc/dev_guide/README.md @@ -6,7 +6,8 @@ 4. [Integration Testing](https://github.com/foss42/apidash/blob/main/doc/dev_guide/integration_testing.md) 5. [List of API Endpoints for Testing](https://github.com/foss42/apidash/blob/main/doc/dev_guide/api_endpoints_for_testing.md) 6. [Packaging API Dash](https://github.com/foss42/apidash/blob/main/doc/dev_guide/packaging.md) -7. Other Topics +7. [OAuth Authentication Limitations](https://github.com/foss42/apidash/blob/main/doc/dev_guide/oauth_authentication_limitations.md) +8. Other Topics - [Flutter Rust Bridge Experiment for Parsing Hurl](https://github.com/foss42/apidash/blob/main/doc/dev_guide/flutter_rust_bridge_experiment.md) ## Code Walkthrough for New Contributors diff --git a/doc/dev_guide/oauth_authentication_limitations.md b/doc/dev_guide/oauth_authentication_limitations.md new file mode 100644 index 000000000..3f7392a55 --- /dev/null +++ b/doc/dev_guide/oauth_authentication_limitations.md @@ -0,0 +1,182 @@ +# OAuth Authentication Limitations + +This document outlines the current limitations and implementation details of OAuth authentication in API Dash. + +## Table of Contents + +1. [OAuth2 Limitations](#oauth2-limitations) +2. [OAuth1 Limitations](#oauth1-limitations) +3. [Platform-Specific Behavior](#platform-specific-behavior) +4. [Technical Implementation Details](#technical-implementation-details) +5. [Workarounds](#workarounds) +6. [Future Improvements](#future-improvements) + +## OAuth2 Limitations + +### Response Format Restriction + +**Limitation**: OAuth2 implementation only supports `application/json` response format as specified in [RFC 6749, Section 5.1](https://tools.ietf.org/html/rfc6749#section-5.1). + +**Details**: +- The OAuth2 client automatically sets the `Accept: application/json` header for token requests +- Servers that return token responses in other formats (e.g., `application/x-www-form-urlencoded`, `text/plain`) are not supported +- This is enforced by the `_JsonAcceptClient` wrapper in the HTTP client manager + +**Impact**: +- Some legacy OAuth2 providers that don't return JSON responses will fail +- Non-standard OAuth2 implementations may not work correctly + +**Code Reference**: +```dart +// In HttpClientManager.createClientWithJsonAccept() +class _JsonAcceptClient extends http.BaseClient { + @override + Future send(http.BaseRequest request) { + request.headers['Accept'] = 'application/json'; + return _inner.send(request); + } +} +``` + +### Port Range Limitation (Desktop Only) + +**Limitation**: For desktop platforms, the OAuth2 callback server requires at least one free port in the range 8080-8090. + +**Details**: +- The callback server attempts to bind to ports starting from 8080 +- If all ports in the range (8080-8090) are occupied, the OAuth flow will fail +- This only affects desktop platforms (macOS, Windows, Linux) + +**Impact**: +- Users running other services on these ports may experience OAuth failures +- Development environments with multiple applications may conflict + +**Code Reference**: +```dart +// In OAuthCallbackServer.start() +for (int port = 8080; port <= 8090; port++) { + try { + _server = await HttpServer.bind(InternetAddress.loopbackIPv4, port); + _port = port; + break; + } catch (e) { + if (port == 8090) { + throw Exception('Unable to find available port for OAuth callback server'); + } + } +} +``` + +## OAuth1 Limitations + +### Incomplete Flow Implementation + +**Limitation**: OAuth1 implementation does not handle the complete OAuth1 authorization flow. + +**Details**: +- The implementation assumes that the necessary steps to obtain the access token have already been performed manually or through a backend service +- Users must provide pre-obtained access tokens and token secrets +- The three-legged OAuth1 flow (request token → user authorization → access token) is not implemented +- This aligns with the behavior in other API clients such as Postman and Insomnia + +**Impact**: +- Users cannot complete OAuth1 authentication entirely within API Dash +- External tools or manual processes are required to obtain tokens +- Limited to scenarios where tokens are already available + +**Workaround**: +Users need to: +1. Obtain request tokens from the OAuth1 provider +2. Complete user authorization outside of API Dash +3. Exchange the authorized request token for an access token +4. Manually enter the access token and token secret in API Dash + +## Platform-Specific Behavior + +### Redirect URI Handling + +**Mobile Platforms (iOS, Android)**: +- **Default Redirect URI**: `apidash://oauth2` +- **Mechanism**: Uses `flutter_web_auth_2` with custom URL scheme +- **User Experience**: Opens authorization in a WebView within the app + +**Desktop Platforms (macOS, Windows, Linux)**: +- **Default Redirect URI**: `http://localhost:{port}/callback` +- **Port Range**: 8080-8090 (automatically selects first available port) +- **Mechanism**: Opens authorization in the system's default browser +- **User Experience**: External browser window with automatic callback handling + +**Code Reference**: +```dart +// Platform detection logic +static bool get shouldUseLocalhostCallback => isDesktop; + +// Redirect URL determination +if (PlatformUtils.shouldUseLocalhostCallback) { + callbackServer = OAuthCallbackServer(); + final localhostUrl = await callbackServer.start(); + actualRedirectUrl = Uri.parse(localhostUrl); +} else { + // Use custom scheme for mobile + actualRedirectUrl = redirectUrl; // apidash://oauth2 +} +``` + +## Technical Implementation Details + +### Grant Types Supported + +**OAuth2**: +- ✅ Authorization Code Grant +- ✅ Client Credentials Grant +- ✅ Resource Owner Password Grant +- ❌ Implicit Grant (deprecated by OAuth2.1) +- ❌ Device Authorization Grant + +**OAuth1**: +- ✅ Manual token entry (post-authorization) +- ❌ Automated three-legged flow + +### PKCE Support + +**Status**: ✅ Supported for Authorization Code Grant +- Code Challenge Method: SHA-256 or Plaintext +- Automatically generates code verifier and challenge +- Configurable through the UI + +### Token Storage + +**Mechanism**: File-based credential storage +- **Location**: `{workspaceFolderPath}/oauth2_credentials.json` +- **Format**: JSON with access token, refresh token, expiration time +- **Security**: Stored as plain text (limitation for local development tool) + +**Auto-refresh**: ✅ Supported +- Automatically refreshes expired tokens using refresh tokens +- Updates stored credentials file + +## Workarounds + +### For Non-JSON OAuth2 Responses + +If you encounter an OAuth2 provider that doesn't return JSON responses: + +1. **Contact the provider** to request JSON support (recommended) +2. **Use a proxy server** to convert the response format +3. **Consider alternative authentication methods** if available + +### For Port Conflicts on Desktop + +If ports 8080-8090 are occupied: + +1. **Stop conflicting services** temporarily during OAuth flow +2. **Use mobile platform** for OAuth authentication if possible +3. **Configure OAuth provider** to use a different callback URL (if supported) + +## Related Documentation + +- [Setup and Run Guide](setup_run.md) +- [Platform-Specific Instructions](platform_specific_instructions.md) +- [Testing Guide](testing.md) +- [OAuth2 RFC 6749](https://tools.ietf.org/html/rfc6749) +- [OAuth1 RFC 5849](https://tools.ietf.org/html/rfc5849) diff --git a/doc/user_guide/authentication.md b/doc/user_guide/authentication.md new file mode 100644 index 000000000..d5a036f98 --- /dev/null +++ b/doc/user_guide/authentication.md @@ -0,0 +1,337 @@ +# Authentication in API Dash + +This guide explains how to authenticate your API requests in API Dash. We’ll start from zero and walk through each supported method with plain-language steps, examples, and tips. + +Use this page when you need your requests to include credentials (tokens, keys, usernames/passwords, etc.). + + +> ![Authentication tab overview — Request screen with Authentication tab highlighted and Auth Type dropdown visible](images/auth/auth-tab-overview.png) + +--- + +## Where to find authentication + +1. Open any request (or create a new one). +2. Switch to the “Authentication” tab. +3. Use the “Authentication Type” dropdown to select how you want to authenticate. + +> ![Auth Type dropdown showing options (None, API Key, Bearer, Basic, Digest, JWT, OAuth 1.0, OAuth 2.0)](images/auth/auth-type-dropdown.png) + +When a type is selected, API Dash shows the relevant fields. As you type, your request preview updates and API Dash will attach the credentials to the outgoing request either in headers or in the URL (depending on the method). + +--- + +## Supported methods at a glance + +- None (No Auth) +- API Key +- Bearer Token +- Basic Auth (username & password) +- Digest Auth +- JWT Bearer (signed JSON Web Token) +- OAuth 1.0 +- OAuth 2.0 (Authorization Code, Resource Owner Password, Client Credentials; PKCE support) + +Each section below explains what it is, when to use it, and exactly how to fill it in API Dash. + +--- + +## None (No Auth) + +- Use when your API is public or doesn’t require credentials. +- API Dash won’t add any Authorization headers or query parameters. + +Steps: +1. In Authentication Type, select “None”. +2. Send your request as usual. + +--- + +## API Key + +API key auth sends a single key-value pair with your request, either in a header or as a query parameter. + +Typical use: Public APIs that issue static keys for access control. + +What API Dash sends: +- If “Add to” is Header: a header named with the key as its value (default name: `x-api-key`). +- If “Add to” is Query Params: a `?=` appended to the URL. + +Fields in API Dash: +- Add to: Header or Query Params +- Header/Query Param Name: default `x-api-key` (editable) +- API Key: your actual key value + +Steps: +1. Select “API Key”. +2. Choose “Add to”: Header (default) or Query Params. +3. Set the “Header/Query Param Name” (leave as `x-api-key` unless your API expects a different name). +4. Paste your API key in “API Key”. +5. Send the request. + +> ![API Key fields — Add to, Header/Query Param Name, and API Key](images/auth/api-key-fields.png) + +Example result (Header mode): +- Header: `x-api-key: ` + +--- + +## Bearer Token + +Bearer tokens are access tokens like OAuth 2.0 access tokens or other opaque tokens your API provides. + +What API Dash sends: +- Header: `Authorization: Bearer ` + +Fields in API Dash: +- Token + +Steps: +1. Select “Bearer Token”. +2. Paste your token into “Token”. +3. Send the request. + +> ![Bearer Token field — single Token input](images/auth/bearer-fields.png) + +--- + +## Basic Auth + +Basic authentication encodes a username and password and sends them in the Authorization header. + +What API Dash sends: +- Header: `Authorization: Basic ` + +Fields in API Dash: +- Username +- Password + +Steps: +1. Select “Basic Auth”. +2. Enter your Username and Password. +3. Send the request. + +> ![Basic Auth fields — Username and Password](images/auth/basic-fields.png) + +Security note: Basic Auth is only safe over HTTPS. Avoid using it over plain HTTP. + +--- + +## Digest Auth + +Digest authentication is a challenge-response mechanism. The server provides a challenge (realm, nonce, etc.), and the client computes a response using a hash function. + +When to use: If the API server requires Digest Auth (you’ll typically see a 401 with `WWW-Authenticate: Digest ...`). + +What API Dash sends: +- An `Authorization: Digest ...` header built using the fields below. + +Fields in API Dash: +- Username: your account username +- Password: your account password (hashed in the auth process, not sent in plain text) +- Realm: protection space defined by the server (from server challenge) +- Nonce: server-provided random value +- Algorithm: hashing algorithm (e.g., MD5, SHA-256, SHA-512 depending on server) +- QOP: quality of protection (commonly `auth` or `auth-int`) +- Opaque: server-provided string to echo back + +Steps: +1. Select “Digest”. +2. Fill Username and Password. +3. Fill the challenge details (Realm, Nonce, Opaque) from the server’s 401 response(done automatically by APIDash). +4. Choose the Algorithm and QOP your server requires. +5. Send the request again. + +> ![Digest Auth fields — Username, Password, Realm, Nonce, Algorithm, QOP, Opaque](images/auth/digest-fields.png) + +Tip: If you don’t have Realm/Nonce/Opaque yet, send the request without them, APIDash will fill them for you. + +--- + +## JWT Bearer (signed JSON Web Token) + +JWT lets you create and sign a token that your API accepts as a credential. API Dash can sign a JWT for you based on your inputs. + +What API Dash sends: +- If “Add JWT token to” is Header: `Authorization: Bearer ` +- If “Add JWT token to” is Query Params: `?token=` + +Fields in API Dash: +- Add JWT token to: Header or Query Params +- Algorithm: select the signing algorithm + - HS...: HMAC with SHA (requires a Secret) + - RS...: RSA (requires a Private Key) + - ES...: ECDSA (requires a Private Key) + - PS...: RSA-PSS (requires a Private Key) +- Secret: the HMAC secret (for HS algorithms) +- Secret is Base64 encoded: check if your secret is base64 +- Private Key: PKCS#8 PEM for RS/ES/PS algorithms +- Payload: JSON payload to include in the token (e.g., `{"sub":"123","name":"Alice"}`) + +Steps (HS256 example): +1. Select “JWT”. +2. Set “Add JWT token to” -> Header (recommended). +3. Choose Algorithm: HS256. +4. Enter your Secret (check “Secret is Base64 encoded” only if your secret is actually base64 encoded). +5. Enter your JSON Payload. +6. Send the request. API Dash signs and attaches the token. + +Steps (RS256 example): +1. Select “JWT” Bearer. +2. Set “Add JWT token to” -> Header. +3. Choose Algorithm: RS256. +4. Paste your PKCS#8 Private Key into “Private Key”. +5. Enter your JSON Payload. +6. Send the request. + +> ![JWT fields — Add to, Algorithm, Secret or Private Key, and Payload](images/auth/jwt-fields.png) + +Notes: +- Header prefix is `Bearer` when adding to headers. +- Query parameter key is `token` when adding to URL. +- Ensure clocks are in sync if your API validates `iat`, `nbf`, or `exp` claims. + +--- + +## OAuth 1.0 + +OAuth 1.0 signs requests using consumer credentials and (optionally) user access tokens. API Dash generates the OAuth 1.0 signature based on the fields you enter. + +What API Dash sends: +- The OAuth 1.0 parameters and signature, attached according to your configuration. + +Fields in API Dash: +- Consumer Key +- Consumer Secret +- Signature Method: HMAC or PLAINTEXT variants (choose what your API requires) +- Access Token (optional; required for 3-legged flows after authorization) +- Token Secret (optional; pairs with Access Token for signing) +- Callback URL (if your provider requires a callback during authorization) +- Verifier (PIN/code returned by provider after user authorization) +- Timestamp (usually auto-generated by servers/clients; enter only if required) +- Nonce (random string; enter only if required by your flow) +- Realm (optional) + +Steps (high level): +1. Select “OAuth 1.0”. +2. Enter Consumer Key and Consumer Secret. +3. Choose your Signature Method (check your API docs). +4. If your API uses 2‑legged OAuth 1.0, you typically won’t have Access Token/Token Secret. +5. If your API requires an access token (e.g., from a completed 3‑legged flow), paste the Access Token and Token Secret you obtained (and Verifier if your provider requires it). +6. Send your request. + +Note: API Dash does not perform the 3‑legged OAuth 1.0 authorization flow. Obtain tokens externally and enter them here. See “Limitations (OAuth 1.0)” below. + +Credential storage: +- API Dash stores OAuth 1.0 tokens/credentials in your workspace at `oauth1_credentials.json` when applicable. + +> ![OAuth 1.0 fields — Consumer Key/Secret, Signature Method, tokens and advanced fields](images/auth/oauth1-fields.png) + +Tip: Some providers require parameters to be in the URL/body vs. header. API Dash handles parameter placement while signing; ensure your request method/body type matches provider expectations. + +### Limitations (OAuth 1.0) + +- API Dash doesn’t implement the complete three-legged OAuth 1.0 flow (request token → user authorization → access token). Obtain the Access Token and Token Secret (e.g., from provider portal, from your backend, or from another request) and enter them here. + +--- + +## OAuth 2.0 + +OAuth 2.0 issues short-lived access tokens and (optionally) long-lived refresh tokens. API Dash supports common grant types and PKCE. + +Default behavior: +- API Dash sends `Authorization: Bearer ` on requests that use OAuth 2.0. +- Tokens can be read from and written to a credentials file in your workspace, and the UI shows token details if present. + +Grant types supported: +- Authorization Code (with optional PKCE) +- Resource Owner Password (username/password) +- Client Credentials + +Fields in API Dash: +- Grant Type: choose one of the above +- Authorization URL (Authorization Code) +- Access Token URL (all grant types) +- Client ID (all grant types) +- Client Secret (all grant types, except public clients where it’s not used) +- Redirect URL (Authorization Code) +- Scope (space-separated) +- State (Authorization Code) +- Code Challenge Method (Authorization Code with PKCE): SHA-256 or Plaintext +- Username, Password (Resource Owner Password) +- Refresh Token (optional; displayed/stored if provided by server) +- Identity Token (optional; OpenID Connect providers may return this) +- Access Token (current token value, if any) + +Token persistence and session control: +- API Dash uses a credentials file at `oauth2_credentials.json` in your workspace to store tokens returned by the provider. +- If the file includes an expiration timestamp, the UI shows “Token expires in …”. +- Use “Clear OAuth2 Session” to delete the credentials file and reset tokens in the UI. + +> ![OAuth 2.0 fields — Grant Type, URLs, Client ID/Secret, Scope, and token fields](images/auth/oauth2-fields.png) +> ![OAuth 2.0 token expiration status — Access Token with “Token expires in …” message](images/auth/oauth2-expiration.png) + +### Platform-specific behavior + +- Desktop (macOS, Windows, Linux) + - Default Redirect URL: `http://localhost:{port}/callback` + - Port range used by the local callback server: 8080–8090 (first available port) + - Opens your system browser for authorization and handles the callback locally +- Mobile (iOS, Android) + - Default Redirect URL: `apidash://oauth2` + - Uses a custom URL scheme; authorization occurs in-app or via system browser + +### Limitations (OAuth 2.0) + +- Token endpoint responses must be JSON (`application/json`). API Dash sets `Accept: application/json` on token requests; providers returning `application/x-www-form-urlencoded` or `text/plain` are not supported. +- On desktop, the local callback requires a free port between 8080 and 8090. If all are occupied, the flow fails. +- Not supported grant types: Implicit and Device Authorization. + +For technical details, see the developer guide: [OAuth Authentication Limitations](../dev_guide/oauth_authentication_limitations.md). + +Using Authorization Code (with or without PKCE): +1. Select “OAuth 2.0”. +2. Set Grant Type: Authorization Code. +3. Fill Authorization URL, Access Token URL, Client ID, Client Secret (if required), Redirect URL, Scope, and State. +4. If using PKCE, choose the Code Challenge Method (SHA-256 recommended). API Dash will handle code challenge/verifier details during the flow. +5. Complete your provider’s auth steps (you may need to perform the code exchange by calling the token endpoint from a request in API Dash using these values). When the provider returns tokens, API Dash will store them in `oauth2_credentials.json` and show them in the UI. +6. Subsequent requests will include your Access Token automatically. + +Using Resource Owner Password: +1. Select Grant Type: Resource Owner Password. +2. Fill Access Token URL, Client ID/Secret (if required), Scope, Username, and Password. +3. Obtain tokens from the provider (by calling the token endpoint with these values). API Dash will reflect the tokens if saved to the credentials file. + +Using Client Credentials: +1. Select Grant Type: Client Credentials. +2. Fill Access Token URL, Client ID/Secret, and Scope (if required). +3. Obtain and store the Access Token. Requests will include the token automatically. + +Refreshing tokens: +- If your provider returns a Refresh Token, API Dash will auto-refresh access tokens in the background when needed and update `oauth2_credentials.json`. +- You can also paste new token values directly into the fields when testing or overriding. + +--- + +## Troubleshooting & tips + +- Getting 401/403? Double-check the auth type and field values. Verify the header/query params match what your API expects. +- Wrong token location? For API Key and JWT, confirm “Add to” is set correctly. +- OAuth redirect issues? Make sure the Redirect URL in API Dash exactly matches what you registered with the provider. +- Token not appearing? For OAuth 2.0/1.0, check that your credentials file exists in the workspace and contains the expected fields. +- Security: Use HTTPS. Keep secrets and private keys safe. Avoid committing credentials to git. + - Desktop OAuth 2.0: ensure at least one port in 8080–8090 is free; stop conflicting services before starting the flow. + - Non-JSON token responses: if your provider’s token endpoint doesn’t return JSON, the flow isn’t supported. Consider a proxy that converts responses to JSON or contact the provider. + +--- + +## FAQ + +- Can I use custom header names for Bearer tokens? + - If you need a custom header, use API Key auth with the header name `Authorization` and value like `Bearer `. +- Can I add custom JWT headers or change the header prefix? + - API Dash uses the `Bearer` prefix for header-based JWTs and the `token` key for query params. +- Do I have to use the credentials files? + - For OAuth 1.0/2.0, the credentials files help API Dash remember tokens. You can also paste tokens manually. + +You’re set! Pick the auth type, fill the fields, and send your request. diff --git a/doc/user_guide/images/auth/api-key-fields.png b/doc/user_guide/images/auth/api-key-fields.png new file mode 100644 index 000000000..8539e40da Binary files /dev/null and b/doc/user_guide/images/auth/api-key-fields.png differ diff --git a/doc/user_guide/images/auth/auth-tab-overview.png b/doc/user_guide/images/auth/auth-tab-overview.png new file mode 100644 index 000000000..e5650dde7 Binary files /dev/null and b/doc/user_guide/images/auth/auth-tab-overview.png differ diff --git a/doc/user_guide/images/auth/auth-type-dropdown.png b/doc/user_guide/images/auth/auth-type-dropdown.png new file mode 100644 index 000000000..b2ea0e4c8 Binary files /dev/null and b/doc/user_guide/images/auth/auth-type-dropdown.png differ diff --git a/doc/user_guide/images/auth/basic-fields.png b/doc/user_guide/images/auth/basic-fields.png new file mode 100644 index 000000000..f8fe10397 Binary files /dev/null and b/doc/user_guide/images/auth/basic-fields.png differ diff --git a/doc/user_guide/images/auth/bearer-fields.png b/doc/user_guide/images/auth/bearer-fields.png new file mode 100644 index 000000000..dbee22089 Binary files /dev/null and b/doc/user_guide/images/auth/bearer-fields.png differ diff --git a/doc/user_guide/images/auth/digest-fields.png b/doc/user_guide/images/auth/digest-fields.png new file mode 100644 index 000000000..e33400dff Binary files /dev/null and b/doc/user_guide/images/auth/digest-fields.png differ diff --git a/doc/user_guide/images/auth/jwt-fields.png b/doc/user_guide/images/auth/jwt-fields.png new file mode 100644 index 000000000..e76ecfad1 Binary files /dev/null and b/doc/user_guide/images/auth/jwt-fields.png differ diff --git a/doc/user_guide/images/auth/oauth1-fields.png b/doc/user_guide/images/auth/oauth1-fields.png new file mode 100644 index 000000000..c108ab427 Binary files /dev/null and b/doc/user_guide/images/auth/oauth1-fields.png differ diff --git a/doc/user_guide/images/auth/oauth2-expiration.png b/doc/user_guide/images/auth/oauth2-expiration.png new file mode 100644 index 000000000..331990063 Binary files /dev/null and b/doc/user_guide/images/auth/oauth2-expiration.png differ diff --git a/doc/user_guide/images/auth/oauth2-fields.png b/doc/user_guide/images/auth/oauth2-fields.png new file mode 100644 index 000000000..1dda74a14 Binary files /dev/null and b/doc/user_guide/images/auth/oauth2-fields.png differ diff --git a/lib/screens/common_widgets/auth/auth_page.dart b/lib/screens/common_widgets/auth/auth_page.dart index e34b9f06f..65d962595 100644 --- a/lib/screens/common_widgets/auth/auth_page.dart +++ b/lib/screens/common_widgets/auth/auth_page.dart @@ -7,6 +7,8 @@ import 'bearer_auth_fields.dart'; import 'digest_auth_fields.dart'; import 'jwt_auth_fields.dart'; import 'consts.dart'; +import 'oauth1_fields.dart'; +import 'oauth2_field.dart'; class AuthPage extends StatelessWidget { final AuthModel? authModel; @@ -75,6 +77,16 @@ class AuthPage extends StatelessWidget { authData: authModel, updateAuth: updateAuthData, ), + APIAuthType.oauth1 => OAuth1Fields( + readOnly: readOnly, + authData: authModel, + updateAuth: updateAuthData, + ), + APIAuthType.oauth2 => OAuth2Fields( + readOnly: readOnly, + authData: authModel, + updateAuth: updateAuthData, + ), APIAuthType.none => Text(readOnly ? kMsgNoAuth : kMsgNoAuthSelected), _ => Text(readOnly diff --git a/lib/screens/common_widgets/auth/consts.dart b/lib/screens/common_widgets/auth/consts.dart index 479f09801..9a585020f 100644 --- a/lib/screens/common_widgets/auth/consts.dart +++ b/lib/screens/common_widgets/auth/consts.dart @@ -1,5 +1,22 @@ const kEmpty = ''; +enum OAuth2Field { + authorizationUrl, + accessTokenUrl, + clientId, + clientSecret, + redirectUrl, + scope, + state, + codeChallengeMethod, + username, + password, + refreshToken, + identityToken, + accessToken, + clearSession, +} + // API Key Auth const kApiKeyHeaderName = 'x-api-key'; const kAddToLocations = [ @@ -63,6 +80,82 @@ const kHintJson = const kHeaderPrefix = 'Bearer'; const kQueryParamKey = 'token'; +// OAuth1 Auth +const kHintOAuth1ConsumerKey = "Consumer Key"; +const kInfoOAuth1ConsumerKey = + "The consumer key provided by the service provider to identify your application."; +const kHintOAuth1ConsumerSecret = "Consumer Secret"; +const kInfoOAuth1ConsumerSecret = + "The consumer secret provided by the service provider. Keep this secure and never expose it publicly."; +const kHintOAuth1AccessToken = "Access Token"; +const kInfoOAuth1AccessToken = + "The access token obtained after user authorization. This represents the user's permission to access their data."; +const kHintOAuth1TokenSecret = "Token Secret"; +const kInfoOAuth1TokenSecret = + "The token secret associated with the access token. Used to sign requests along with the consumer secret."; +const kHintOAuth1CallbackUrl = "Callback URL"; +const kInfoOAuth1CallbackUrl = + "The URL where the user will be redirected after authorization. Must match the URL registered with the service provider."; +const kHintOAuth1Verifier = "Verifier"; +const kInfoOAuth1Verifier = + "The verification code received after user authorization. Used to exchange the request token for an access token."; +const kHintOAuth1Timestamp = "Timestamp"; +const kInfoOAuth1Timestamp = + "Unix timestamp when the request is made. Usually generated automatically to prevent replay attacks."; +const kHintOAuth1Nonce = "Nonce"; +const kInfoOAuth1Nonce = + "A unique random string for each request. Helps prevent replay attacks and ensures request uniqueness."; +const kHintOAuth1Realm = "Realm"; +const kInfoOAuth1Realm = + "Optional realm parameter that defines the protection space. Some services require this for proper authentication."; +const kLabelOAuth1SignatureMethod = "Signature Method"; +const kTooltipOAuth1SignatureMethod = + "Select the signature method for OAuth 1.0 authentication"; + +// OAuth2 Auth +const kLabelOAuth2GrantType = "Grant Type"; +const kTooltipOAuth2GrantType = "Select OAuth 2.0 grant type"; +const kHintOAuth2AuthorizationUrl = "Authorization URL"; +const kInfoOAuth2AuthorizationUrl = + "The authorization endpoint URL where users are redirected to grant permission to your application."; +const kHintOAuth2AccessTokenUrl = "Access Token URL"; +const kInfoOAuth2AccessTokenUrl = + "The token endpoint URL where authorization codes are exchanged for access tokens."; +const kHintOAuth2ClientId = "Client ID"; +const kInfoOAuth2ClientId = + "The client identifier issued to your application by the authorization server."; +const kHintOAuth2ClientSecret = "Client Secret"; +const kInfoOAuth2ClientSecret = + "The client secret issued to your application. Keep this secure and never expose it publicly."; +const kHintOAuth2RedirectUrl = "Redirect URL"; +const kInfoOAuth2RedirectUrl = + "The URL where users are redirected after authorization. Must match the URL registered with the service."; +const kHintOAuth2Scope = "Scope"; +const kInfoOAuth2Scope = + "Space-separated list of permissions your application is requesting access to."; +const kHintOAuth2State = "State"; +const kInfoOAuth2State = + "An unguessable random string used to protect against cross-site request forgery attacks."; +const kHintOAuth2Username = "Username"; +const kInfoOAuth2Username = + "Your username for resource owner password credentials grant type."; +const kHintOAuth2Password = "Password"; +const kInfoOAuth2Password = + "Your password for resource owner password credentials grant type."; +const kHintOAuth2RefreshToken = "Refresh Token"; +const kInfoOAuth2RefreshToken = + "Token used to obtain new access tokens when the current access token expires."; +const kHintOAuth2IdentityToken = "Identity Token"; +const kInfoOAuth2IdentityToken = + "JWT token containing user identity information, typically used in OpenID Connect flows."; +const kHintOAuth2AccessToken = "Access Token"; +const kInfoOAuth2AccessToken = + "The token used to access protected resources on behalf of the user."; +const kLabelOAuth2CodeChallengeMethod = "Code Challenge Method"; +const kTooltipOAuth2CodeChallengeMethod = + "Code challenge method for PKCE (Proof Key for Code Exchange)"; +const kButtonClearOAuth2Session = "Clear OAuth2 Session"; + //AuthPAge const kLabelSelectAuthType = "Authentication Type"; const kTooltipSelectAuth = "Select Authentication Type"; diff --git a/lib/screens/common_widgets/auth/oauth1_fields.dart b/lib/screens/common_widgets/auth/oauth1_fields.dart new file mode 100644 index 000000000..beae59694 --- /dev/null +++ b/lib/screens/common_widgets/auth/oauth1_fields.dart @@ -0,0 +1,266 @@ +import 'package:apidash/providers/settings_providers.dart'; +import 'package:apidash_core/apidash_core.dart'; +import 'package:apidash_design_system/widgets/widgets.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../common_widgets.dart'; +import 'consts.dart'; + +class OAuth1Fields extends ConsumerStatefulWidget { + final AuthModel? authData; + + final bool readOnly; + + final Function(AuthModel?)? updateAuth; + + const OAuth1Fields({ + super.key, + required this.authData, + required this.updateAuth, + this.readOnly = false, + }); + + @override + ConsumerState createState() => _OAuth1FieldsState(); +} + +class _OAuth1FieldsState extends ConsumerState { + late String _consumerKey; + late String _consumerSecret; + late String _accessToken; + late String _tokenSecret; + late String _callbackUrl; + late String _verifier; + late String _timestamp; + late String _realm; + late String _nonce; + late OAuth1SignatureMethod _signatureMethodController; + late String _addAuthDataTo; + + @override + void initState() { + super.initState(); + final oauth1 = widget.authData?.oauth1; + _consumerKey = oauth1?.consumerKey ?? ''; + _consumerSecret = oauth1?.consumerSecret ?? ''; + _accessToken = oauth1?.accessToken ?? ''; + _tokenSecret = oauth1?.tokenSecret ?? ''; + _callbackUrl = oauth1?.callbackUrl ?? ''; + _verifier = oauth1?.verifier ?? ''; + _timestamp = oauth1?.timestamp ?? ''; + _realm = oauth1?.realm ?? ''; + _nonce = oauth1?.nonce ?? ''; + _signatureMethodController = + oauth1?.signatureMethod ?? OAuth1SignatureMethod.hmacSha1; + _addAuthDataTo = oauth1?.parameterLocation ?? 'url'; + } + + @override + Widget build(BuildContext context) { + return ListView( + physics: ClampingScrollPhysics(), + shrinkWrap: true, + children: [ + // Text( + // "Add auth data to", + // style: TextStyle( + // fontWeight: FontWeight.normal, + // fontSize: 14, + // ), + // ), + // SizedBox( + // height: 4, + // ), + // ADPopupMenu( + // value: _addAuthDataTo, + // values: const [ + // ('Request URL / Request Body', 'url'), + // ('Request Header', 'header'), + // ], + // tooltip: "Select where to add API key", + // isOutlined: true, + // onChanged: (String? newLocation) { + // if (newLocation != null) { + // setState(() { + // _addAuthDataTo = newLocation; + // }); + + // _updateOAuth1(); + // } + // }, + // ), + // const SizedBox(height: 16), + EnvAuthField( + readOnly: widget.readOnly, + initialValue: _consumerKey, + hintText: kHintOAuth1ConsumerKey, + infoText: kInfoOAuth1ConsumerKey, + onChanged: (value) { + _consumerKey = value; + _updateOAuth1(); + }, + ), + const SizedBox(height: 16), + EnvAuthField( + readOnly: widget.readOnly, + initialValue: _consumerSecret, + hintText: kHintOAuth1ConsumerSecret, + infoText: kInfoOAuth1ConsumerSecret, + isObscureText: true, + onChanged: (value) { + _consumerSecret = value; + _updateOAuth1(); + }, + ), + const SizedBox(height: 16), + Text( + kLabelOAuth1SignatureMethod, + style: TextStyle( + fontWeight: FontWeight.normal, + fontSize: 14, + ), + ), + const SizedBox(height: 4), + ADPopupMenu( + value: _signatureMethodController.displayType, + values: OAuth1SignatureMethod.values.map((e) => (e, e.displayType)), + tooltip: kTooltipOAuth1SignatureMethod, + isOutlined: true, + onChanged: widget.readOnly + ? null + : (OAuth1SignatureMethod? newAlgo) { + if (newAlgo != null) { + setState(() { + _signatureMethodController = newAlgo; + }); + _updateOAuth1(); + } + }, + ), + const SizedBox(height: 16), + EnvAuthField( + readOnly: widget.readOnly, + initialValue: _accessToken, + hintText: kHintOAuth1AccessToken, + infoText: kInfoOAuth1AccessToken, + onChanged: (value) { + _accessToken = value; + _updateOAuth1(); + }, + ), + const SizedBox(height: 16), + EnvAuthField( + readOnly: widget.readOnly, + initialValue: _tokenSecret, + hintText: kHintOAuth1TokenSecret, + infoText: kInfoOAuth1TokenSecret, + isObscureText: true, + onChanged: (value) { + _tokenSecret = value; + _updateOAuth1(); + }, + ), + const SizedBox(height: 16), + EnvAuthField( + readOnly: widget.readOnly, + initialValue: _callbackUrl, + hintText: kHintOAuth1CallbackUrl, + infoText: kInfoOAuth1CallbackUrl, + onChanged: (value) { + _callbackUrl = value; + _updateOAuth1(); + }, + ), + const SizedBox(height: 16), + EnvAuthField( + readOnly: widget.readOnly, + initialValue: _verifier, + hintText: kHintOAuth1Verifier, + infoText: kInfoOAuth1Verifier, + onChanged: (value) { + _verifier = value; + _updateOAuth1(); + }, + ), + const SizedBox(height: 16), + EnvAuthField( + readOnly: widget.readOnly, + initialValue: _timestamp, + hintText: kHintOAuth1Timestamp, + infoText: kInfoOAuth1Timestamp, + onChanged: (value) { + _timestamp = value; + _updateOAuth1(); + }, + ), + const SizedBox(height: 16), + EnvAuthField( + readOnly: widget.readOnly, + initialValue: _nonce, + hintText: kHintOAuth1Nonce, + infoText: kInfoOAuth1Nonce, + onChanged: (value) { + _nonce = value; + _updateOAuth1(); + }, + ), + const SizedBox(height: 16), + EnvAuthField( + readOnly: widget.readOnly, + initialValue: _realm, + hintText: kHintOAuth1Realm, + infoText: kInfoOAuth1Realm, + onChanged: (value) { + _realm = value; + _updateOAuth1(); + }, + ), + const SizedBox(height: 16), + ], + ); + } + + void _updateOAuth1() async { + final settingsModel = ref.read(settingsProvider); + final credentialsFilePath = settingsModel.workspaceFolderPath != null + ? "${settingsModel.workspaceFolderPath}/oauth1_credentials.json" + : null; + + widget.updateAuth?.call( + widget.authData?.copyWith( + type: APIAuthType.oauth1, + oauth1: AuthOAuth1Model( + consumerKey: _consumerKey.trim(), + consumerSecret: _consumerSecret.trim(), + accessToken: _accessToken.trim(), + tokenSecret: _tokenSecret.trim(), + signatureMethod: _signatureMethodController, + parameterLocation: _addAuthDataTo, + callbackUrl: _callbackUrl.trim(), + verifier: _verifier.trim(), + timestamp: _timestamp.trim(), + nonce: _nonce.trim(), + realm: _realm.trim(), + credentialsFilePath: credentialsFilePath, + ), + ) ?? + AuthModel( + type: APIAuthType.oauth1, + oauth1: AuthOAuth1Model( + consumerKey: _consumerKey.trim(), + consumerSecret: _consumerSecret.trim(), + accessToken: _accessToken.trim(), + tokenSecret: _tokenSecret.trim(), + signatureMethod: _signatureMethodController, + parameterLocation: _addAuthDataTo, + callbackUrl: _callbackUrl.trim(), + verifier: _verifier.trim(), + timestamp: _timestamp.trim(), + nonce: _nonce.trim(), + realm: _realm.trim(), + credentialsFilePath: credentialsFilePath, + ), + ), + ); + } +} diff --git a/lib/screens/common_widgets/auth/oauth2_field.dart b/lib/screens/common_widgets/auth/oauth2_field.dart new file mode 100644 index 000000000..8f35665d0 --- /dev/null +++ b/lib/screens/common_widgets/auth/oauth2_field.dart @@ -0,0 +1,475 @@ +import 'dart:convert'; + +import 'package:apidash/providers/settings_providers.dart'; +import 'package:apidash/providers/collection_providers.dart'; +import 'package:apidash/models/models.dart'; +import 'package:apidash/utils/file_utils.dart'; +import 'package:apidash_core/apidash_core.dart'; +import 'package:apidash_design_system/apidash_design_system.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../common_widgets.dart'; +import 'consts.dart'; +import 'utils.dart'; + +class OAuth2Fields extends ConsumerStatefulWidget { + final AuthModel? authData; + final bool readOnly; + final Function(AuthModel?)? updateAuth; + const OAuth2Fields({ + super.key, + required this.authData, + this.updateAuth, + this.readOnly = false, + }); + @override + ConsumerState createState() => _OAuth2FieldsState(); +} + +class _OAuth2FieldsState extends ConsumerState { + late OAuth2GrantType _grantType; + late String _authorizationUrl; + late String _accessTokenUrl; + late String _clientId; + late String _clientSecret; + late String _redirectUrl; + late String _scope; + late String _state; + late String _codeChallengeMethod; + late String _username; + late String _password; + late String _refreshToken; + late String _identityToken; + late String _accessToken; + DateTime? _tokenExpiration; + + @override + void initState() { + super.initState(); + final oauth2 = widget.authData?.oauth2; + _grantType = oauth2?.grantType ?? OAuth2GrantType.authorizationCode; + _authorizationUrl = oauth2?.authorizationUrl ?? ''; + _accessTokenUrl = oauth2?.accessTokenUrl ?? ''; + _clientId = oauth2?.clientId ?? ''; + _clientSecret = oauth2?.clientSecret ?? ''; + _redirectUrl = oauth2?.redirectUrl ?? ''; + _scope = oauth2?.scope ?? ''; + _state = oauth2?.state ?? ''; + _username = oauth2?.username ?? ''; + _password = oauth2?.password ?? ''; + _refreshToken = oauth2?.refreshToken ?? ''; + _identityToken = oauth2?.identityToken ?? ''; + _accessToken = oauth2?.accessToken ?? ''; + _codeChallengeMethod = oauth2?.codeChallengeMethod ?? 'sha-256'; + + // Load credentials from file if available + _loadCredentialsFromFile(); + } + + Future _loadCredentialsFromFile() async { + final credentialsFilePath = widget.authData?.oauth2?.credentialsFilePath; + if (credentialsFilePath != null && credentialsFilePath.isNotEmpty) { + try { + final credentialsFile = await loadFileFromPath(credentialsFilePath); + if (credentialsFile != null) { + final credentials = await credentialsFile.readAsString(); + if (credentials.isNotEmpty) { + final Map decoded = jsonDecode(credentials); + setState(() { + if (decoded['refreshToken'] != null) { + _refreshToken = decoded['refreshToken']!; + } else { + _refreshToken = "N/A"; + } + if (decoded['idToken'] != null) { + _identityToken = decoded['idToken']!; + } else { + _identityToken = "N/A"; + } + if (decoded['accessToken'] != null) { + _accessToken = decoded['accessToken']!; + } else { + _accessToken = "N/A"; + } + // Parse expiration time + if (decoded['expiration'] != null) { + _tokenExpiration = + DateTime.fromMillisecondsSinceEpoch(decoded['expiration']!); + } else { + _tokenExpiration = null; + } + }); + } + } + } catch (e) { + // Handle file reading or JSON parsing errors silently + debugPrint('Error loading OAuth2 credentials: $e'); + } + } + } + + @override + Widget build(BuildContext context) { + // Watch for changes in the selected request model's HTTP response + ref.listen(selectedRequestModelProvider, (previous, next) { + // Check if the HTTP response has changed (new response received) + if (previous?.httpResponseModel != next?.httpResponseModel && + next?.httpResponseModel != null) { + // Only reload if this request uses OAuth2 auth and has credentials file path + final authModel = next?.httpRequestModel?.authModel; + if (authModel?.type == APIAuthType.oauth2 && + authModel?.oauth2?.credentialsFilePath != null) { + // Small delay to ensure file is written before reading + Future.delayed(const Duration(milliseconds: 100), () { + _loadCredentialsFromFile(); + }); + } + } + }); + + return ListView( + shrinkWrap: true, + physics: ClampingScrollPhysics(), + children: [ + Text( + kLabelOAuth2GrantType, + style: Theme.of(context).textTheme.labelLarge, + ), + kVSpacer5, + ADPopupMenu( + value: _grantType.displayType, + values: OAuth2GrantType.values.map((e) => (e, e.displayType)), + tooltip: kTooltipOAuth2GrantType, + isOutlined: true, + onChanged: widget.readOnly + ? null + : (OAuth2GrantType? newGrantType) { + if (newGrantType != null && newGrantType != _grantType) { + setState(() { + _grantType = newGrantType; + }); + + _updateOAuth2(); + } + }, + ), + kVSpacer16, + if (_shouldShowField(OAuth2Field.authorizationUrl)) + ..._buildFieldWithSpacing( + EnvAuthField( + readOnly: widget.readOnly, + initialValue: _authorizationUrl, + hintText: kHintOAuth2AuthorizationUrl, + infoText: kInfoOAuth2AuthorizationUrl, + onChanged: (value) { + _authorizationUrl = value; + + _updateOAuth2(); + }, + ), + ), + if (_shouldShowField(OAuth2Field.username)) + ..._buildFieldWithSpacing( + EnvAuthField( + readOnly: widget.readOnly, + initialValue: _username, + hintText: kHintOAuth2Username, + infoText: kInfoOAuth2Username, + onChanged: (value) { + _username = value; + + _updateOAuth2(); + }, + ), + ), + if (_shouldShowField(OAuth2Field.password)) + ..._buildFieldWithSpacing( + EnvAuthField( + readOnly: widget.readOnly, + initialValue: _password, + hintText: kHintOAuth2Password, + infoText: kInfoOAuth2Password, + isObscureText: true, + onChanged: (value) { + _password = value; + + _updateOAuth2(); + }, + ), + ), + if (_shouldShowField(OAuth2Field.accessTokenUrl)) + ..._buildFieldWithSpacing( + EnvAuthField( + readOnly: widget.readOnly, + initialValue: _accessTokenUrl, + hintText: kHintOAuth2AccessTokenUrl, + infoText: kInfoOAuth2AccessTokenUrl, + onChanged: (value) { + _accessTokenUrl = value; + _updateOAuth2(); + }, + ), + ), + if (_shouldShowField(OAuth2Field.clientId)) + ..._buildFieldWithSpacing( + EnvAuthField( + readOnly: widget.readOnly, + initialValue: _clientId, + hintText: kHintOAuth2ClientId, + infoText: kInfoOAuth2ClientId, + onChanged: (value) { + _clientId = value; + + _updateOAuth2(); + }, + ), + ), + if (_shouldShowField(OAuth2Field.clientSecret)) + ..._buildFieldWithSpacing( + EnvAuthField( + readOnly: widget.readOnly, + initialValue: _clientSecret, + hintText: kHintOAuth2ClientSecret, + infoText: kInfoOAuth2ClientSecret, + isObscureText: true, + onChanged: (value) { + _clientSecret = value; + + _updateOAuth2(); + }, + ), + ), + if (_shouldShowField(OAuth2Field.codeChallengeMethod)) ...[ + Text( + kLabelOAuth2CodeChallengeMethod, + style: Theme.of(context).textTheme.labelLarge, + ), + kVSpacer5, + ADPopupMenu( + value: _codeChallengeMethod.toUpperCase(), + values: const [ + ('SHA-256', 'sha-256'), + ('Plaintext', 'plaintext'), + ], + tooltip: kTooltipOAuth2CodeChallengeMethod, + isOutlined: true, + onChanged: widget.readOnly + ? null + : (String? newMethod) { + if (newMethod != null && + newMethod != _codeChallengeMethod) { + setState(() { + _codeChallengeMethod = newMethod; + }); + + _updateOAuth2(); + } + }, + ), + kVSpacer16, + ], + if (_shouldShowField(OAuth2Field.redirectUrl)) + ..._buildFieldWithSpacing( + EnvAuthField( + readOnly: widget.readOnly, + initialValue: _redirectUrl, + hintText: kHintOAuth2RedirectUrl, + infoText: kInfoOAuth2RedirectUrl, + onChanged: (value) { + _redirectUrl = value; + + _updateOAuth2(); + }, + ), + ), + if (_shouldShowField(OAuth2Field.scope)) + ..._buildFieldWithSpacing( + EnvAuthField( + readOnly: widget.readOnly, + initialValue: _scope, + hintText: kHintOAuth2Scope, + infoText: kInfoOAuth2Scope, + onChanged: (value) { + _scope = value; + + _updateOAuth2(); + }, + ), + ), + if (_shouldShowField(OAuth2Field.state)) + ..._buildFieldWithSpacing( + EnvAuthField( + readOnly: widget.readOnly, + initialValue: _state, + hintText: kHintOAuth2State, + infoText: kInfoOAuth2State, + onChanged: (value) { + _state = value; + + _updateOAuth2(); + }, + ), + ), + ..._buildFieldWithSpacing( + Align( + alignment: Alignment.centerRight, + child: ADTextButton( + label: kButtonClearOAuth2Session, + onPressed: clearStoredCredentials, + ), + ), + ), + Divider(), + kVSpacer16, + ..._buildFieldWithSpacing( + EnvAuthField( + readOnly: widget.readOnly, + initialValue: _refreshToken, + hintText: kHintOAuth2RefreshToken, + infoText: kInfoOAuth2RefreshToken, + onChanged: (value) { + _refreshToken = value; + + _updateOAuth2(); + }, + ), + ), + ..._buildFieldWithSpacing( + EnvAuthField( + readOnly: widget.readOnly, + initialValue: _identityToken, + hintText: kHintOAuth2IdentityToken, + infoText: kInfoOAuth2IdentityToken, + onChanged: (value) { + _identityToken = value; + + _updateOAuth2(); + }, + ), + ), + ..._buildFieldWithSpacing( + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + EnvAuthField( + readOnly: widget.readOnly, + initialValue: _accessToken, + hintText: kHintOAuth2AccessToken, + infoText: kInfoOAuth2AccessToken, + onChanged: (value) { + _accessToken = value; + + _updateOAuth2(); + }, + ), + if (_tokenExpiration != null) ...[ + const SizedBox(height: 4), + Text( + getExpirationText(_tokenExpiration), + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: _tokenExpiration!.isBefore(DateTime.now()) + ? Theme.of(context).colorScheme.error + : Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ], + ], + ), + ), + kVSpacer16, + ], + ); + } + + List _buildFieldWithSpacing(Widget field) { + return [ + field, + kVSpacer16, + ]; + } + + bool _shouldShowField(OAuth2Field field) { + const alwaysShownFields = { + OAuth2Field.accessTokenUrl, + OAuth2Field.clientId, + OAuth2Field.clientSecret, + OAuth2Field.scope, + OAuth2Field.refreshToken, + OAuth2Field.identityToken, + OAuth2Field.accessToken, + OAuth2Field.clearSession, + }; + + if (alwaysShownFields.contains(field)) { + return true; + } + + switch (_grantType) { + case OAuth2GrantType.authorizationCode: + return const { + OAuth2Field.authorizationUrl, + OAuth2Field.redirectUrl, + OAuth2Field.codeChallengeMethod, + OAuth2Field.state, + }.contains(field); + + case OAuth2GrantType.resourceOwnerPassword: + return const { + OAuth2Field.username, + OAuth2Field.password, + }.contains(field); + + case OAuth2GrantType.clientCredentials: + return false; + } + } + + void _updateOAuth2() async { + final String? credentialsFilePath = + ref.read(settingsProvider).workspaceFolderPath; + + final updatedOAuth2 = AuthOAuth2Model( + grantType: _grantType, + authorizationUrl: _authorizationUrl.trim(), + clientId: _clientId.trim(), + accessTokenUrl: _accessTokenUrl.trim(), + clientSecret: _clientSecret.trim(), + credentialsFilePath: credentialsFilePath != null + ? "$credentialsFilePath/oauth2_credentials.json" + : null, + codeChallengeMethod: _codeChallengeMethod, + redirectUrl: _redirectUrl.trim(), + scope: _scope.trim(), + state: _state.trim(), + username: _username.trim(), + password: _password.trim(), + refreshToken: _refreshToken.trim(), + identityToken: _identityToken.trim(), + accessToken: _accessToken.trim(), + ); + + widget.updateAuth?.call( + widget.authData?.copyWith( + type: APIAuthType.oauth2, + oauth2: updatedOAuth2, + ) ?? + AuthModel( + type: APIAuthType.oauth2, + oauth2: updatedOAuth2, + ), + ); + } + + Future clearStoredCredentials() async { + final credentialsFilePath = widget.authData?.oauth2?.credentialsFilePath; + if (credentialsFilePath != null && credentialsFilePath.isNotEmpty) { + await deleteFileFromPath(credentialsFilePath); + } + setState(() { + _refreshToken = ""; + _accessToken = ""; + _identityToken = ""; + _tokenExpiration = null; + }); + } +} diff --git a/lib/screens/common_widgets/auth/utils.dart b/lib/screens/common_widgets/auth/utils.dart new file mode 100644 index 000000000..ddd650aa7 --- /dev/null +++ b/lib/screens/common_widgets/auth/utils.dart @@ -0,0 +1,22 @@ +String getExpirationText(DateTime? tokenExpiration) { + if (tokenExpiration == null) { + return ""; + } + + final now = DateTime.now(); + if (tokenExpiration.isBefore(now)) { + return "Token expired"; + } else { + // For future times, we want to show "in X hours" instead of "X hours from now" + final duration = tokenExpiration.difference(now); + if (duration.inDays > 0) { + return "Token expires in ${duration.inDays} day${duration.inDays > 1 ? 's' : ''}"; + } else if (duration.inHours > 0) { + return "Token expires in ${duration.inHours} hour${duration.inHours > 1 ? 's' : ''}"; + } else if (duration.inMinutes > 0) { + return "Token expires in ${duration.inMinutes} minute${duration.inMinutes > 1 ? 's' : ''}"; + } else { + return "Token expires in less than a minute"; + } + } +} diff --git a/lib/utils/envvar_utils.dart b/lib/utils/envvar_utils.dart index 3738aa476..f817dd967 100644 --- a/lib/utils/envvar_utils.dart +++ b/lib/utils/envvar_utils.dart @@ -168,7 +168,84 @@ AuthModel? substituteAuthModel( } break; case APIAuthType.oauth1: + if (authModel.oauth1 != null) { + final oauth1 = authModel.oauth1!; + return authModel.copyWith( + oauth1: oauth1.copyWith( + consumerKey: substituteVariables(oauth1.consumerKey, envVarMap) ?? + oauth1.consumerKey, + consumerSecret: + substituteVariables(oauth1.consumerSecret, envVarMap) ?? + oauth1.consumerSecret, + credentialsFilePath: + substituteVariables(oauth1.credentialsFilePath, envVarMap) ?? + oauth1.credentialsFilePath, + accessToken: substituteVariables(oauth1.accessToken, envVarMap) ?? + oauth1.accessToken, + tokenSecret: substituteVariables(oauth1.tokenSecret, envVarMap) ?? + oauth1.tokenSecret, + parameterLocation: + substituteVariables(oauth1.parameterLocation, envVarMap) ?? + oauth1.parameterLocation, + version: substituteVariables(oauth1.version, envVarMap) ?? + oauth1.version, + realm: substituteVariables(oauth1.realm, envVarMap) ?? oauth1.realm, + callbackUrl: substituteVariables(oauth1.callbackUrl, envVarMap) ?? + oauth1.callbackUrl, + verifier: substituteVariables(oauth1.verifier, envVarMap) ?? + oauth1.verifier, + nonce: substituteVariables(oauth1.nonce, envVarMap) ?? oauth1.nonce, + timestamp: substituteVariables(oauth1.timestamp, envVarMap) ?? + oauth1.timestamp, + ), + ); + } + break; case APIAuthType.oauth2: + if (authModel.oauth2 != null) { + final oauth2 = authModel.oauth2!; + return authModel.copyWith( + oauth2: oauth2.copyWith( + authorizationUrl: + substituteVariables(oauth2.authorizationUrl, envVarMap) ?? + oauth2.authorizationUrl, + accessTokenUrl: + substituteVariables(oauth2.accessTokenUrl, envVarMap) ?? + oauth2.accessTokenUrl, + clientId: substituteVariables(oauth2.clientId, envVarMap) ?? + oauth2.clientId, + clientSecret: substituteVariables(oauth2.clientSecret, envVarMap) ?? + oauth2.clientSecret, + credentialsFilePath: + substituteVariables(oauth2.credentialsFilePath, envVarMap) ?? + oauth2.credentialsFilePath, + redirectUrl: substituteVariables(oauth2.redirectUrl, envVarMap) ?? + oauth2.redirectUrl, + scope: substituteVariables(oauth2.scope, envVarMap) ?? oauth2.scope, + state: substituteVariables(oauth2.state, envVarMap) ?? oauth2.state, + codeChallengeMethod: + substituteVariables(oauth2.codeChallengeMethod, envVarMap) ?? + oauth2.codeChallengeMethod, + codeVerifier: substituteVariables(oauth2.codeVerifier, envVarMap) ?? + oauth2.codeVerifier, + codeChallenge: + substituteVariables(oauth2.codeChallenge, envVarMap) ?? + oauth2.codeChallenge, + username: substituteVariables(oauth2.username, envVarMap) ?? + oauth2.username, + password: substituteVariables(oauth2.password, envVarMap) ?? + oauth2.password, + refreshToken: substituteVariables(oauth2.refreshToken, envVarMap) ?? + oauth2.refreshToken, + identityToken: + substituteVariables(oauth2.identityToken, envVarMap) ?? + oauth2.identityToken, + accessToken: substituteVariables(oauth2.accessToken, envVarMap) ?? + oauth2.accessToken, + ), + ); + } + break; case APIAuthType.none: break; } diff --git a/lib/utils/file_utils.dart b/lib/utils/file_utils.dart index 8f7c6a720..072178444 100644 --- a/lib/utils/file_utils.dart +++ b/lib/utils/file_utils.dart @@ -34,6 +34,19 @@ Future getFileDownloadpath(String? name, String? ext) async { return null; } +Future getApplicationSupportDirectoryFilePath( + String name, String? ext) async { + final Directory tempDir = await getApplicationSupportDirectory(); + name = name; + ext = (ext != null) ? ".$ext" : ""; + String path = '${tempDir.path}/$name$ext'; + int num = 1; + while (await File(path).exists()) { + path = '${tempDir.path}/$name (${num++})$ext'; + } + return path; +} + Future saveFile(String path, Uint8List content) async { final file = File(path); await file.writeAsBytes(content); @@ -61,3 +74,28 @@ Future pickFile() async { XFile? pickedResult = await openFile(); return pickedResult; } + +Future loadFileFromPath(String filePath) async { + try { + final file = File(filePath); + if (!await file.exists()) { + return null; + } + return file; + } catch (e) { + return null; + } +} + +Future deleteFileFromPath(String filePath) async { + try { + final file = File(filePath); + if (await file.exists()) { + await file.delete(); + return true; + } + return false; + } catch (e) { + return false; + } +} diff --git a/packages/better_networking/example/pubspec.lock b/packages/better_networking/example/pubspec.lock index a2159d2e6..2cf904b41 100644 --- a/packages/better_networking/example/pubspec.lock +++ b/packages/better_networking/example/pubspec.lock @@ -88,6 +88,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.2.0" + desktop_webview_window: + dependency: transitive + description: + name: desktop_webview_window + sha256: "57cf20d81689d5cbb1adfd0017e96b669398a669d927906073b0e42fc64111c0" + url: "https://pub.dev" + source: hosted + version: "0.2.3" ed25519_edwards: dependency: transitive description: @@ -104,6 +112,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.3" + ffi: + dependency: transitive + description: + name: ffi + sha256: "289279317b4b16eb2bb7e271abccd4bf84ec9bdcbe999e278a94b804f5630418" + url: "https://pub.dev" + source: hosted + version: "2.1.4" fixnum: dependency: transitive description: @@ -130,6 +146,27 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_web_auth_2: + dependency: transitive + description: + name: flutter_web_auth_2 + sha256: "2483d1fd3c45fe1262446e8d5f5490f01b864f2e7868ffe05b4727e263cc0182" + url: "https://pub.dev" + source: hosted + version: "5.0.0-alpha.3" + flutter_web_auth_2_platform_interface: + dependency: transitive + description: + name: flutter_web_auth_2_platform_interface + sha256: "45927587ebb2364cd273675ec95f6f67b81725754b416cef2b65cdc63fd3e853" + url: "https://pub.dev" + source: hosted + version: "5.0.0-alpha.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" freezed_annotation: dependency: transitive description: @@ -226,6 +263,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.16.0" + oauth1: + dependency: transitive + description: + name: oauth1 + sha256: "1d424e3c24017a6c5714acb12e0dd76c2fdff96db6d6ef0aab58c925ffc28ae0" + url: "https://pub.dev" + source: hosted + version: "2.1.0" + oauth2: + dependency: transitive + description: + name: oauth2 + sha256: c84470642cbb2bec450ccab2f8520c079cd1ca546a76ffd5c40589e07f4e8bf4 + url: "https://pub.dev" + source: hosted + version: "2.0.3" path: dependency: transitive description: @@ -234,6 +287,54 @@ packages: url: "https://pub.dev" source: hosted version: "1.9.1" + path_provider: + dependency: transitive + description: + name: path_provider + sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" + url: "https://pub.dev" + source: hosted + version: "2.1.5" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + sha256: d0d310befe2c8ab9e7f393288ccbb11b60c019c6b5afc21973eeee4dda2b35e9 + url: "https://pub.dev" + source: hosted + version: "2.2.17" + path_provider_foundation: + dependency: transitive + description: + name: path_provider_foundation + sha256: "4843174df4d288f5e29185bd6e72a6fbdf5a4a4602717eed565497429f179942" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279 + url: "https://pub.dev" + source: hosted + version: "2.2.1" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 + url: "https://pub.dev" + source: hosted + version: "2.3.0" petitparser: dependency: transitive description: @@ -242,6 +343,22 @@ packages: url: "https://pub.dev" source: hosted version: "6.1.0" + platform: + dependency: transitive + description: + name: platform + sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" + url: "https://pub.dev" + source: hosted + version: "3.1.6" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" + source: hosted + version: "2.1.8" pointycastle: dependency: transitive description: @@ -318,6 +435,70 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.0" + url_launcher: + dependency: transitive + description: + name: url_launcher + sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8 + url: "https://pub.dev" + source: hosted + version: "6.3.2" + url_launcher_android: + dependency: transitive + description: + name: url_launcher_android + sha256: "8582d7f6fe14d2652b4c45c9b6c14c0b678c2af2d083a11b604caeba51930d79" + url: "https://pub.dev" + source: hosted + version: "6.3.16" + url_launcher_ios: + dependency: transitive + description: + name: url_launcher_ios + sha256: "7f2022359d4c099eea7df3fdf739f7d3d3b9faf3166fb1dd390775176e0b76cb" + url: "https://pub.dev" + source: hosted + version: "6.3.3" + url_launcher_linux: + dependency: transitive + description: + name: url_launcher_linux + sha256: "4e9ba368772369e3e08f231d2301b4ef72b9ff87c31192ef471b380ef29a4935" + url: "https://pub.dev" + source: hosted + version: "3.2.1" + url_launcher_macos: + dependency: transitive + description: + name: url_launcher_macos + sha256: "17ba2000b847f334f16626a574c702b196723af2a289e7a93ffcb79acff855c2" + url: "https://pub.dev" + source: hosted + version: "3.2.2" + url_launcher_platform_interface: + dependency: transitive + description: + name: url_launcher_platform_interface + sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + url_launcher_web: + dependency: transitive + description: + name: url_launcher_web + sha256: "4bd2b7b4dc4d4d0b94e5babfffbca8eac1a126c7f3d6ecbc1a11013faa3abba2" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + url_launcher_windows: + dependency: transitive + description: + name: url_launcher_windows + sha256: "3284b6d2ac454cf34f114e1d3319866fdd1e19cdc329999057e44ffe936cfa77" + url: "https://pub.dev" + source: hosted + version: "3.1.4" vector_math: dependency: transitive description: @@ -342,6 +523,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.1" + window_to_front: + dependency: transitive + description: + name: window_to_front + sha256: "7aef379752b7190c10479e12b5fd7c0b9d92adc96817d9e96c59937929512aee" + url: "https://pub.dev" + source: hosted + version: "0.0.3" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" + url: "https://pub.dev" + source: hosted + version: "1.1.0" xml: dependency: transitive description: @@ -352,4 +549,4 @@ packages: version: "6.5.0" sdks: dart: ">=3.8.0 <4.0.0" - flutter: ">=3.18.0-18.0.pre.54" + flutter: ">=3.27.0" diff --git a/packages/better_networking/lib/consts.dart b/packages/better_networking/lib/consts.dart index 3b370596d..002cc2e7f 100644 --- a/packages/better_networking/lib/consts.dart +++ b/packages/better_networking/lib/consts.dart @@ -51,6 +51,23 @@ const kJwtAlgos = [ 'EdDSA', ]; +enum OAuth2GrantType { + authorizationCode("Authorization Code"), + clientCredentials("Client Credentials"), + resourceOwnerPassword("Resource Owner Password"); + + const OAuth2GrantType(this.displayType); + final String displayType; +} + +enum OAuth1SignatureMethod { + hmacSha1("HMAC-SHA1"), + plaintext("Plaintext"); + + const OAuth1SignatureMethod(this.displayType); + final String displayType; +} + enum HTTPVerb { get("GET"), head("HEAD"), diff --git a/packages/better_networking/lib/models/auth/api_auth_model.dart b/packages/better_networking/lib/models/auth/api_auth_model.dart index 824ab543a..64ea2f3db 100644 --- a/packages/better_networking/lib/models/auth/api_auth_model.dart +++ b/packages/better_networking/lib/models/auth/api_auth_model.dart @@ -5,6 +5,8 @@ import 'auth_basic_model.dart'; import 'auth_bearer_model.dart'; import 'auth_jwt_model.dart'; import 'auth_digest_model.dart'; +import 'auth_oauth1_model.dart'; +import 'auth_oauth2_model.dart'; part 'api_auth_model.g.dart'; part 'api_auth_model.freezed.dart'; @@ -19,6 +21,8 @@ class AuthModel with _$AuthModel { AuthBasicAuthModel? basic, AuthJwtModel? jwt, AuthDigestModel? digest, + AuthOAuth1Model? oauth1, + AuthOAuth2Model? oauth2, }) = _AuthModel; factory AuthModel.fromJson(Map json) => diff --git a/packages/better_networking/lib/models/auth/api_auth_model.freezed.dart b/packages/better_networking/lib/models/auth/api_auth_model.freezed.dart index 4e592fcc8..eb2d62162 100644 --- a/packages/better_networking/lib/models/auth/api_auth_model.freezed.dart +++ b/packages/better_networking/lib/models/auth/api_auth_model.freezed.dart @@ -27,6 +27,8 @@ mixin _$AuthModel { AuthBasicAuthModel? get basic => throw _privateConstructorUsedError; AuthJwtModel? get jwt => throw _privateConstructorUsedError; AuthDigestModel? get digest => throw _privateConstructorUsedError; + AuthOAuth1Model? get oauth1 => throw _privateConstructorUsedError; + AuthOAuth2Model? get oauth2 => throw _privateConstructorUsedError; /// Serializes this AuthModel to a JSON map. Map toJson() => throw _privateConstructorUsedError; @@ -50,6 +52,8 @@ abstract class $AuthModelCopyWith<$Res> { AuthBasicAuthModel? basic, AuthJwtModel? jwt, AuthDigestModel? digest, + AuthOAuth1Model? oauth1, + AuthOAuth2Model? oauth2, }); $AuthApiKeyModelCopyWith<$Res>? get apikey; @@ -57,6 +61,8 @@ abstract class $AuthModelCopyWith<$Res> { $AuthBasicAuthModelCopyWith<$Res>? get basic; $AuthJwtModelCopyWith<$Res>? get jwt; $AuthDigestModelCopyWith<$Res>? get digest; + $AuthOAuth1ModelCopyWith<$Res>? get oauth1; + $AuthOAuth2ModelCopyWith<$Res>? get oauth2; } /// @nodoc @@ -80,6 +86,8 @@ class _$AuthModelCopyWithImpl<$Res, $Val extends AuthModel> Object? basic = freezed, Object? jwt = freezed, Object? digest = freezed, + Object? oauth1 = freezed, + Object? oauth2 = freezed, }) { return _then( _value.copyWith( @@ -107,6 +115,14 @@ class _$AuthModelCopyWithImpl<$Res, $Val extends AuthModel> ? _value.digest : digest // ignore: cast_nullable_to_non_nullable as AuthDigestModel?, + oauth1: freezed == oauth1 + ? _value.oauth1 + : oauth1 // ignore: cast_nullable_to_non_nullable + as AuthOAuth1Model?, + oauth2: freezed == oauth2 + ? _value.oauth2 + : oauth2 // ignore: cast_nullable_to_non_nullable + as AuthOAuth2Model?, ) as $Val, ); @@ -181,6 +197,34 @@ class _$AuthModelCopyWithImpl<$Res, $Val extends AuthModel> return _then(_value.copyWith(digest: value) as $Val); }); } + + /// Create a copy of AuthModel + /// with the given fields replaced by the non-null parameter values. + @override + @pragma('vm:prefer-inline') + $AuthOAuth1ModelCopyWith<$Res>? get oauth1 { + if (_value.oauth1 == null) { + return null; + } + + return $AuthOAuth1ModelCopyWith<$Res>(_value.oauth1!, (value) { + return _then(_value.copyWith(oauth1: value) as $Val); + }); + } + + /// Create a copy of AuthModel + /// with the given fields replaced by the non-null parameter values. + @override + @pragma('vm:prefer-inline') + $AuthOAuth2ModelCopyWith<$Res>? get oauth2 { + if (_value.oauth2 == null) { + return null; + } + + return $AuthOAuth2ModelCopyWith<$Res>(_value.oauth2!, (value) { + return _then(_value.copyWith(oauth2: value) as $Val); + }); + } } /// @nodoc @@ -199,6 +243,8 @@ abstract class _$$AuthModelImplCopyWith<$Res> AuthBasicAuthModel? basic, AuthJwtModel? jwt, AuthDigestModel? digest, + AuthOAuth1Model? oauth1, + AuthOAuth2Model? oauth2, }); @override @@ -211,6 +257,10 @@ abstract class _$$AuthModelImplCopyWith<$Res> $AuthJwtModelCopyWith<$Res>? get jwt; @override $AuthDigestModelCopyWith<$Res>? get digest; + @override + $AuthOAuth1ModelCopyWith<$Res>? get oauth1; + @override + $AuthOAuth2ModelCopyWith<$Res>? get oauth2; } /// @nodoc @@ -233,6 +283,8 @@ class __$$AuthModelImplCopyWithImpl<$Res> Object? basic = freezed, Object? jwt = freezed, Object? digest = freezed, + Object? oauth1 = freezed, + Object? oauth2 = freezed, }) { return _then( _$AuthModelImpl( @@ -260,6 +312,14 @@ class __$$AuthModelImplCopyWithImpl<$Res> ? _value.digest : digest // ignore: cast_nullable_to_non_nullable as AuthDigestModel?, + oauth1: freezed == oauth1 + ? _value.oauth1 + : oauth1 // ignore: cast_nullable_to_non_nullable + as AuthOAuth1Model?, + oauth2: freezed == oauth2 + ? _value.oauth2 + : oauth2 // ignore: cast_nullable_to_non_nullable + as AuthOAuth2Model?, ), ); } @@ -276,6 +336,8 @@ class _$AuthModelImpl implements _AuthModel { this.basic, this.jwt, this.digest, + this.oauth1, + this.oauth2, }); factory _$AuthModelImpl.fromJson(Map json) => @@ -293,10 +355,14 @@ class _$AuthModelImpl implements _AuthModel { final AuthJwtModel? jwt; @override final AuthDigestModel? digest; + @override + final AuthOAuth1Model? oauth1; + @override + final AuthOAuth2Model? oauth2; @override String toString() { - return 'AuthModel(type: $type, apikey: $apikey, bearer: $bearer, basic: $basic, jwt: $jwt, digest: $digest)'; + return 'AuthModel(type: $type, apikey: $apikey, bearer: $bearer, basic: $basic, jwt: $jwt, digest: $digest, oauth1: $oauth1, oauth2: $oauth2)'; } @override @@ -309,13 +375,24 @@ class _$AuthModelImpl implements _AuthModel { (identical(other.bearer, bearer) || other.bearer == bearer) && (identical(other.basic, basic) || other.basic == basic) && (identical(other.jwt, jwt) || other.jwt == jwt) && - (identical(other.digest, digest) || other.digest == digest)); + (identical(other.digest, digest) || other.digest == digest) && + (identical(other.oauth1, oauth1) || other.oauth1 == oauth1) && + (identical(other.oauth2, oauth2) || other.oauth2 == oauth2)); } @JsonKey(includeFromJson: false, includeToJson: false) @override - int get hashCode => - Object.hash(runtimeType, type, apikey, bearer, basic, jwt, digest); + int get hashCode => Object.hash( + runtimeType, + type, + apikey, + bearer, + basic, + jwt, + digest, + oauth1, + oauth2, + ); /// Create a copy of AuthModel /// with the given fields replaced by the non-null parameter values. @@ -339,6 +416,8 @@ abstract class _AuthModel implements AuthModel { final AuthBasicAuthModel? basic, final AuthJwtModel? jwt, final AuthDigestModel? digest, + final AuthOAuth1Model? oauth1, + final AuthOAuth2Model? oauth2, }) = _$AuthModelImpl; factory _AuthModel.fromJson(Map json) = @@ -356,6 +435,10 @@ abstract class _AuthModel implements AuthModel { AuthJwtModel? get jwt; @override AuthDigestModel? get digest; + @override + AuthOAuth1Model? get oauth1; + @override + AuthOAuth2Model? get oauth2; /// Create a copy of AuthModel /// with the given fields replaced by the non-null parameter values. diff --git a/packages/better_networking/lib/models/auth/api_auth_model.g.dart b/packages/better_networking/lib/models/auth/api_auth_model.g.dart index 7b6ac4182..92b064d67 100644 --- a/packages/better_networking/lib/models/auth/api_auth_model.g.dart +++ b/packages/better_networking/lib/models/auth/api_auth_model.g.dart @@ -31,6 +31,16 @@ _$AuthModelImpl _$$AuthModelImplFromJson(Map json) => _$AuthModelImpl( : AuthDigestModel.fromJson( Map.from(json['digest'] as Map), ), + oauth1: json['oauth1'] == null + ? null + : AuthOAuth1Model.fromJson( + Map.from(json['oauth1'] as Map), + ), + oauth2: json['oauth2'] == null + ? null + : AuthOAuth2Model.fromJson( + Map.from(json['oauth2'] as Map), + ), ); Map _$$AuthModelImplToJson(_$AuthModelImpl instance) => @@ -41,6 +51,8 @@ Map _$$AuthModelImplToJson(_$AuthModelImpl instance) => 'basic': instance.basic?.toJson(), 'jwt': instance.jwt?.toJson(), 'digest': instance.digest?.toJson(), + 'oauth1': instance.oauth1?.toJson(), + 'oauth2': instance.oauth2?.toJson(), }; const _$APIAuthTypeEnumMap = { diff --git a/packages/better_networking/lib/models/auth/auth_oauth1_model.dart b/packages/better_networking/lib/models/auth/auth_oauth1_model.dart new file mode 100644 index 000000000..981682dc0 --- /dev/null +++ b/packages/better_networking/lib/models/auth/auth_oauth1_model.dart @@ -0,0 +1,30 @@ +import 'package:better_networking/consts.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'auth_oauth1_model.g.dart'; + +part 'auth_oauth1_model.freezed.dart'; + +@freezed +class AuthOAuth1Model with _$AuthOAuth1Model { + const factory AuthOAuth1Model({ + required String consumerKey, + required String consumerSecret, + String? credentialsFilePath, + String? accessToken, + String? tokenSecret, + @Default(OAuth1SignatureMethod.hmacSha1) + OAuth1SignatureMethod signatureMethod, + @Default("header") String parameterLocation, + @Default('1.0') String version, + String? realm, + String? callbackUrl, + String? verifier, + String? nonce, + String? timestamp, + @Default(false) bool includeBodyHash, + }) = _AuthOAuth1Model; + + factory AuthOAuth1Model.fromJson(Map json) => + _$AuthOAuth1ModelFromJson(json); +} diff --git a/packages/better_networking/lib/models/auth/auth_oauth1_model.freezed.dart b/packages/better_networking/lib/models/auth/auth_oauth1_model.freezed.dart new file mode 100644 index 000000000..87735f493 --- /dev/null +++ b/packages/better_networking/lib/models/auth/auth_oauth1_model.freezed.dart @@ -0,0 +1,474 @@ +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'auth_oauth1_model.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +T _$identity(T value) => value; + +final _privateConstructorUsedError = UnsupportedError( + 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models', +); + +AuthOAuth1Model _$AuthOAuth1ModelFromJson(Map json) { + return _AuthOAuth1Model.fromJson(json); +} + +/// @nodoc +mixin _$AuthOAuth1Model { + String get consumerKey => throw _privateConstructorUsedError; + String get consumerSecret => throw _privateConstructorUsedError; + String? get credentialsFilePath => throw _privateConstructorUsedError; + String? get accessToken => throw _privateConstructorUsedError; + String? get tokenSecret => throw _privateConstructorUsedError; + OAuth1SignatureMethod get signatureMethod => + throw _privateConstructorUsedError; + String get parameterLocation => throw _privateConstructorUsedError; + String get version => throw _privateConstructorUsedError; + String? get realm => throw _privateConstructorUsedError; + String? get callbackUrl => throw _privateConstructorUsedError; + String? get verifier => throw _privateConstructorUsedError; + String? get nonce => throw _privateConstructorUsedError; + String? get timestamp => throw _privateConstructorUsedError; + bool get includeBodyHash => throw _privateConstructorUsedError; + + /// Serializes this AuthOAuth1Model to a JSON map. + Map toJson() => throw _privateConstructorUsedError; + + /// Create a copy of AuthOAuth1Model + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + $AuthOAuth1ModelCopyWith get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $AuthOAuth1ModelCopyWith<$Res> { + factory $AuthOAuth1ModelCopyWith( + AuthOAuth1Model value, + $Res Function(AuthOAuth1Model) then, + ) = _$AuthOAuth1ModelCopyWithImpl<$Res, AuthOAuth1Model>; + @useResult + $Res call({ + String consumerKey, + String consumerSecret, + String? credentialsFilePath, + String? accessToken, + String? tokenSecret, + OAuth1SignatureMethod signatureMethod, + String parameterLocation, + String version, + String? realm, + String? callbackUrl, + String? verifier, + String? nonce, + String? timestamp, + bool includeBodyHash, + }); +} + +/// @nodoc +class _$AuthOAuth1ModelCopyWithImpl<$Res, $Val extends AuthOAuth1Model> + implements $AuthOAuth1ModelCopyWith<$Res> { + _$AuthOAuth1ModelCopyWithImpl(this._value, this._then); + + // ignore: unused_field + final $Val _value; + // ignore: unused_field + final $Res Function($Val) _then; + + /// Create a copy of AuthOAuth1Model + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? consumerKey = null, + Object? consumerSecret = null, + Object? credentialsFilePath = freezed, + Object? accessToken = freezed, + Object? tokenSecret = freezed, + Object? signatureMethod = null, + Object? parameterLocation = null, + Object? version = null, + Object? realm = freezed, + Object? callbackUrl = freezed, + Object? verifier = freezed, + Object? nonce = freezed, + Object? timestamp = freezed, + Object? includeBodyHash = null, + }) { + return _then( + _value.copyWith( + consumerKey: null == consumerKey + ? _value.consumerKey + : consumerKey // ignore: cast_nullable_to_non_nullable + as String, + consumerSecret: null == consumerSecret + ? _value.consumerSecret + : consumerSecret // ignore: cast_nullable_to_non_nullable + as String, + credentialsFilePath: freezed == credentialsFilePath + ? _value.credentialsFilePath + : credentialsFilePath // ignore: cast_nullable_to_non_nullable + as String?, + accessToken: freezed == accessToken + ? _value.accessToken + : accessToken // ignore: cast_nullable_to_non_nullable + as String?, + tokenSecret: freezed == tokenSecret + ? _value.tokenSecret + : tokenSecret // ignore: cast_nullable_to_non_nullable + as String?, + signatureMethod: null == signatureMethod + ? _value.signatureMethod + : signatureMethod // ignore: cast_nullable_to_non_nullable + as OAuth1SignatureMethod, + parameterLocation: null == parameterLocation + ? _value.parameterLocation + : parameterLocation // ignore: cast_nullable_to_non_nullable + as String, + version: null == version + ? _value.version + : version // ignore: cast_nullable_to_non_nullable + as String, + realm: freezed == realm + ? _value.realm + : realm // ignore: cast_nullable_to_non_nullable + as String?, + callbackUrl: freezed == callbackUrl + ? _value.callbackUrl + : callbackUrl // ignore: cast_nullable_to_non_nullable + as String?, + verifier: freezed == verifier + ? _value.verifier + : verifier // ignore: cast_nullable_to_non_nullable + as String?, + nonce: freezed == nonce + ? _value.nonce + : nonce // ignore: cast_nullable_to_non_nullable + as String?, + timestamp: freezed == timestamp + ? _value.timestamp + : timestamp // ignore: cast_nullable_to_non_nullable + as String?, + includeBodyHash: null == includeBodyHash + ? _value.includeBodyHash + : includeBodyHash // ignore: cast_nullable_to_non_nullable + as bool, + ) + as $Val, + ); + } +} + +/// @nodoc +abstract class _$$AuthOAuth1ModelImplCopyWith<$Res> + implements $AuthOAuth1ModelCopyWith<$Res> { + factory _$$AuthOAuth1ModelImplCopyWith( + _$AuthOAuth1ModelImpl value, + $Res Function(_$AuthOAuth1ModelImpl) then, + ) = __$$AuthOAuth1ModelImplCopyWithImpl<$Res>; + @override + @useResult + $Res call({ + String consumerKey, + String consumerSecret, + String? credentialsFilePath, + String? accessToken, + String? tokenSecret, + OAuth1SignatureMethod signatureMethod, + String parameterLocation, + String version, + String? realm, + String? callbackUrl, + String? verifier, + String? nonce, + String? timestamp, + bool includeBodyHash, + }); +} + +/// @nodoc +class __$$AuthOAuth1ModelImplCopyWithImpl<$Res> + extends _$AuthOAuth1ModelCopyWithImpl<$Res, _$AuthOAuth1ModelImpl> + implements _$$AuthOAuth1ModelImplCopyWith<$Res> { + __$$AuthOAuth1ModelImplCopyWithImpl( + _$AuthOAuth1ModelImpl _value, + $Res Function(_$AuthOAuth1ModelImpl) _then, + ) : super(_value, _then); + + /// Create a copy of AuthOAuth1Model + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? consumerKey = null, + Object? consumerSecret = null, + Object? credentialsFilePath = freezed, + Object? accessToken = freezed, + Object? tokenSecret = freezed, + Object? signatureMethod = null, + Object? parameterLocation = null, + Object? version = null, + Object? realm = freezed, + Object? callbackUrl = freezed, + Object? verifier = freezed, + Object? nonce = freezed, + Object? timestamp = freezed, + Object? includeBodyHash = null, + }) { + return _then( + _$AuthOAuth1ModelImpl( + consumerKey: null == consumerKey + ? _value.consumerKey + : consumerKey // ignore: cast_nullable_to_non_nullable + as String, + consumerSecret: null == consumerSecret + ? _value.consumerSecret + : consumerSecret // ignore: cast_nullable_to_non_nullable + as String, + credentialsFilePath: freezed == credentialsFilePath + ? _value.credentialsFilePath + : credentialsFilePath // ignore: cast_nullable_to_non_nullable + as String?, + accessToken: freezed == accessToken + ? _value.accessToken + : accessToken // ignore: cast_nullable_to_non_nullable + as String?, + tokenSecret: freezed == tokenSecret + ? _value.tokenSecret + : tokenSecret // ignore: cast_nullable_to_non_nullable + as String?, + signatureMethod: null == signatureMethod + ? _value.signatureMethod + : signatureMethod // ignore: cast_nullable_to_non_nullable + as OAuth1SignatureMethod, + parameterLocation: null == parameterLocation + ? _value.parameterLocation + : parameterLocation // ignore: cast_nullable_to_non_nullable + as String, + version: null == version + ? _value.version + : version // ignore: cast_nullable_to_non_nullable + as String, + realm: freezed == realm + ? _value.realm + : realm // ignore: cast_nullable_to_non_nullable + as String?, + callbackUrl: freezed == callbackUrl + ? _value.callbackUrl + : callbackUrl // ignore: cast_nullable_to_non_nullable + as String?, + verifier: freezed == verifier + ? _value.verifier + : verifier // ignore: cast_nullable_to_non_nullable + as String?, + nonce: freezed == nonce + ? _value.nonce + : nonce // ignore: cast_nullable_to_non_nullable + as String?, + timestamp: freezed == timestamp + ? _value.timestamp + : timestamp // ignore: cast_nullable_to_non_nullable + as String?, + includeBodyHash: null == includeBodyHash + ? _value.includeBodyHash + : includeBodyHash // ignore: cast_nullable_to_non_nullable + as bool, + ), + ); + } +} + +/// @nodoc +@JsonSerializable() +class _$AuthOAuth1ModelImpl implements _AuthOAuth1Model { + const _$AuthOAuth1ModelImpl({ + required this.consumerKey, + required this.consumerSecret, + this.credentialsFilePath, + this.accessToken, + this.tokenSecret, + this.signatureMethod = OAuth1SignatureMethod.hmacSha1, + this.parameterLocation = "header", + this.version = '1.0', + this.realm, + this.callbackUrl, + this.verifier, + this.nonce, + this.timestamp, + this.includeBodyHash = false, + }); + + factory _$AuthOAuth1ModelImpl.fromJson(Map json) => + _$$AuthOAuth1ModelImplFromJson(json); + + @override + final String consumerKey; + @override + final String consumerSecret; + @override + final String? credentialsFilePath; + @override + final String? accessToken; + @override + final String? tokenSecret; + @override + @JsonKey() + final OAuth1SignatureMethod signatureMethod; + @override + @JsonKey() + final String parameterLocation; + @override + @JsonKey() + final String version; + @override + final String? realm; + @override + final String? callbackUrl; + @override + final String? verifier; + @override + final String? nonce; + @override + final String? timestamp; + @override + @JsonKey() + final bool includeBodyHash; + + @override + String toString() { + return 'AuthOAuth1Model(consumerKey: $consumerKey, consumerSecret: $consumerSecret, credentialsFilePath: $credentialsFilePath, accessToken: $accessToken, tokenSecret: $tokenSecret, signatureMethod: $signatureMethod, parameterLocation: $parameterLocation, version: $version, realm: $realm, callbackUrl: $callbackUrl, verifier: $verifier, nonce: $nonce, timestamp: $timestamp, includeBodyHash: $includeBodyHash)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$AuthOAuth1ModelImpl && + (identical(other.consumerKey, consumerKey) || + other.consumerKey == consumerKey) && + (identical(other.consumerSecret, consumerSecret) || + other.consumerSecret == consumerSecret) && + (identical(other.credentialsFilePath, credentialsFilePath) || + other.credentialsFilePath == credentialsFilePath) && + (identical(other.accessToken, accessToken) || + other.accessToken == accessToken) && + (identical(other.tokenSecret, tokenSecret) || + other.tokenSecret == tokenSecret) && + (identical(other.signatureMethod, signatureMethod) || + other.signatureMethod == signatureMethod) && + (identical(other.parameterLocation, parameterLocation) || + other.parameterLocation == parameterLocation) && + (identical(other.version, version) || other.version == version) && + (identical(other.realm, realm) || other.realm == realm) && + (identical(other.callbackUrl, callbackUrl) || + other.callbackUrl == callbackUrl) && + (identical(other.verifier, verifier) || + other.verifier == verifier) && + (identical(other.nonce, nonce) || other.nonce == nonce) && + (identical(other.timestamp, timestamp) || + other.timestamp == timestamp) && + (identical(other.includeBodyHash, includeBodyHash) || + other.includeBodyHash == includeBodyHash)); + } + + @JsonKey(includeFromJson: false, includeToJson: false) + @override + int get hashCode => Object.hash( + runtimeType, + consumerKey, + consumerSecret, + credentialsFilePath, + accessToken, + tokenSecret, + signatureMethod, + parameterLocation, + version, + realm, + callbackUrl, + verifier, + nonce, + timestamp, + includeBodyHash, + ); + + /// Create a copy of AuthOAuth1Model + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @override + @pragma('vm:prefer-inline') + _$$AuthOAuth1ModelImplCopyWith<_$AuthOAuth1ModelImpl> get copyWith => + __$$AuthOAuth1ModelImplCopyWithImpl<_$AuthOAuth1ModelImpl>( + this, + _$identity, + ); + + @override + Map toJson() { + return _$$AuthOAuth1ModelImplToJson(this); + } +} + +abstract class _AuthOAuth1Model implements AuthOAuth1Model { + const factory _AuthOAuth1Model({ + required final String consumerKey, + required final String consumerSecret, + final String? credentialsFilePath, + final String? accessToken, + final String? tokenSecret, + final OAuth1SignatureMethod signatureMethod, + final String parameterLocation, + final String version, + final String? realm, + final String? callbackUrl, + final String? verifier, + final String? nonce, + final String? timestamp, + final bool includeBodyHash, + }) = _$AuthOAuth1ModelImpl; + + factory _AuthOAuth1Model.fromJson(Map json) = + _$AuthOAuth1ModelImpl.fromJson; + + @override + String get consumerKey; + @override + String get consumerSecret; + @override + String? get credentialsFilePath; + @override + String? get accessToken; + @override + String? get tokenSecret; + @override + OAuth1SignatureMethod get signatureMethod; + @override + String get parameterLocation; + @override + String get version; + @override + String? get realm; + @override + String? get callbackUrl; + @override + String? get verifier; + @override + String? get nonce; + @override + String? get timestamp; + @override + bool get includeBodyHash; + + /// Create a copy of AuthOAuth1Model + /// with the given fields replaced by the non-null parameter values. + @override + @JsonKey(includeFromJson: false, includeToJson: false) + _$$AuthOAuth1ModelImplCopyWith<_$AuthOAuth1ModelImpl> get copyWith => + throw _privateConstructorUsedError; +} diff --git a/packages/better_networking/lib/models/auth/auth_oauth1_model.g.dart b/packages/better_networking/lib/models/auth/auth_oauth1_model.g.dart new file mode 100644 index 000000000..7a5cc2f7f --- /dev/null +++ b/packages/better_networking/lib/models/auth/auth_oauth1_model.g.dart @@ -0,0 +1,55 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'auth_oauth1_model.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_$AuthOAuth1ModelImpl _$$AuthOAuth1ModelImplFromJson( + Map json, +) => _$AuthOAuth1ModelImpl( + consumerKey: json['consumerKey'] as String, + consumerSecret: json['consumerSecret'] as String, + credentialsFilePath: json['credentialsFilePath'] as String?, + accessToken: json['accessToken'] as String?, + tokenSecret: json['tokenSecret'] as String?, + signatureMethod: + $enumDecodeNullable( + _$OAuth1SignatureMethodEnumMap, + json['signatureMethod'], + ) ?? + OAuth1SignatureMethod.hmacSha1, + parameterLocation: json['parameterLocation'] as String? ?? "header", + version: json['version'] as String? ?? '1.0', + realm: json['realm'] as String?, + callbackUrl: json['callbackUrl'] as String?, + verifier: json['verifier'] as String?, + nonce: json['nonce'] as String?, + timestamp: json['timestamp'] as String?, + includeBodyHash: json['includeBodyHash'] as bool? ?? false, +); + +Map _$$AuthOAuth1ModelImplToJson( + _$AuthOAuth1ModelImpl instance, +) => { + 'consumerKey': instance.consumerKey, + 'consumerSecret': instance.consumerSecret, + 'credentialsFilePath': instance.credentialsFilePath, + 'accessToken': instance.accessToken, + 'tokenSecret': instance.tokenSecret, + 'signatureMethod': _$OAuth1SignatureMethodEnumMap[instance.signatureMethod]!, + 'parameterLocation': instance.parameterLocation, + 'version': instance.version, + 'realm': instance.realm, + 'callbackUrl': instance.callbackUrl, + 'verifier': instance.verifier, + 'nonce': instance.nonce, + 'timestamp': instance.timestamp, + 'includeBodyHash': instance.includeBodyHash, +}; + +const _$OAuth1SignatureMethodEnumMap = { + OAuth1SignatureMethod.hmacSha1: 'hmacSha1', + OAuth1SignatureMethod.plaintext: 'plaintext', +}; diff --git a/packages/better_networking/lib/models/auth/auth_oauth2_model.dart b/packages/better_networking/lib/models/auth/auth_oauth2_model.dart new file mode 100644 index 000000000..e6dcb8ee5 --- /dev/null +++ b/packages/better_networking/lib/models/auth/auth_oauth2_model.dart @@ -0,0 +1,32 @@ +import 'package:better_networking/consts.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'auth_oauth2_model.g.dart'; + +part 'auth_oauth2_model.freezed.dart'; + +@freezed +class AuthOAuth2Model with _$AuthOAuth2Model { + const factory AuthOAuth2Model({ + @Default(OAuth2GrantType.authorizationCode) OAuth2GrantType grantType, + required String authorizationUrl, + required String accessTokenUrl, + required String clientId, + required String clientSecret, + String? credentialsFilePath, + String? redirectUrl, + String? scope, + String? state, + @Default("sha-256") String codeChallengeMethod, + String? codeVerifier, + String? codeChallenge, + String? username, + String? password, + String? refreshToken, + String? identityToken, + String? accessToken, + }) = _AuthOAuth2Model; + + factory AuthOAuth2Model.fromJson(Map json) => + _$AuthOAuth2ModelFromJson(json); +} diff --git a/packages/better_networking/lib/models/auth/auth_oauth2_model.freezed.dart b/packages/better_networking/lib/models/auth/auth_oauth2_model.freezed.dart new file mode 100644 index 000000000..145d35cf6 --- /dev/null +++ b/packages/better_networking/lib/models/auth/auth_oauth2_model.freezed.dart @@ -0,0 +1,538 @@ +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'auth_oauth2_model.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +T _$identity(T value) => value; + +final _privateConstructorUsedError = UnsupportedError( + 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models', +); + +AuthOAuth2Model _$AuthOAuth2ModelFromJson(Map json) { + return _AuthOAuth2Model.fromJson(json); +} + +/// @nodoc +mixin _$AuthOAuth2Model { + OAuth2GrantType get grantType => throw _privateConstructorUsedError; + String get authorizationUrl => throw _privateConstructorUsedError; + String get accessTokenUrl => throw _privateConstructorUsedError; + String get clientId => throw _privateConstructorUsedError; + String get clientSecret => throw _privateConstructorUsedError; + String? get credentialsFilePath => throw _privateConstructorUsedError; + String? get redirectUrl => throw _privateConstructorUsedError; + String? get scope => throw _privateConstructorUsedError; + String? get state => throw _privateConstructorUsedError; + String get codeChallengeMethod => throw _privateConstructorUsedError; + String? get codeVerifier => throw _privateConstructorUsedError; + String? get codeChallenge => throw _privateConstructorUsedError; + String? get username => throw _privateConstructorUsedError; + String? get password => throw _privateConstructorUsedError; + String? get refreshToken => throw _privateConstructorUsedError; + String? get identityToken => throw _privateConstructorUsedError; + String? get accessToken => throw _privateConstructorUsedError; + + /// Serializes this AuthOAuth2Model to a JSON map. + Map toJson() => throw _privateConstructorUsedError; + + /// Create a copy of AuthOAuth2Model + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + $AuthOAuth2ModelCopyWith get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $AuthOAuth2ModelCopyWith<$Res> { + factory $AuthOAuth2ModelCopyWith( + AuthOAuth2Model value, + $Res Function(AuthOAuth2Model) then, + ) = _$AuthOAuth2ModelCopyWithImpl<$Res, AuthOAuth2Model>; + @useResult + $Res call({ + OAuth2GrantType grantType, + String authorizationUrl, + String accessTokenUrl, + String clientId, + String clientSecret, + String? credentialsFilePath, + String? redirectUrl, + String? scope, + String? state, + String codeChallengeMethod, + String? codeVerifier, + String? codeChallenge, + String? username, + String? password, + String? refreshToken, + String? identityToken, + String? accessToken, + }); +} + +/// @nodoc +class _$AuthOAuth2ModelCopyWithImpl<$Res, $Val extends AuthOAuth2Model> + implements $AuthOAuth2ModelCopyWith<$Res> { + _$AuthOAuth2ModelCopyWithImpl(this._value, this._then); + + // ignore: unused_field + final $Val _value; + // ignore: unused_field + final $Res Function($Val) _then; + + /// Create a copy of AuthOAuth2Model + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? grantType = null, + Object? authorizationUrl = null, + Object? accessTokenUrl = null, + Object? clientId = null, + Object? clientSecret = null, + Object? credentialsFilePath = freezed, + Object? redirectUrl = freezed, + Object? scope = freezed, + Object? state = freezed, + Object? codeChallengeMethod = null, + Object? codeVerifier = freezed, + Object? codeChallenge = freezed, + Object? username = freezed, + Object? password = freezed, + Object? refreshToken = freezed, + Object? identityToken = freezed, + Object? accessToken = freezed, + }) { + return _then( + _value.copyWith( + grantType: null == grantType + ? _value.grantType + : grantType // ignore: cast_nullable_to_non_nullable + as OAuth2GrantType, + authorizationUrl: null == authorizationUrl + ? _value.authorizationUrl + : authorizationUrl // ignore: cast_nullable_to_non_nullable + as String, + accessTokenUrl: null == accessTokenUrl + ? _value.accessTokenUrl + : accessTokenUrl // ignore: cast_nullable_to_non_nullable + as String, + clientId: null == clientId + ? _value.clientId + : clientId // ignore: cast_nullable_to_non_nullable + as String, + clientSecret: null == clientSecret + ? _value.clientSecret + : clientSecret // ignore: cast_nullable_to_non_nullable + as String, + credentialsFilePath: freezed == credentialsFilePath + ? _value.credentialsFilePath + : credentialsFilePath // ignore: cast_nullable_to_non_nullable + as String?, + redirectUrl: freezed == redirectUrl + ? _value.redirectUrl + : redirectUrl // ignore: cast_nullable_to_non_nullable + as String?, + scope: freezed == scope + ? _value.scope + : scope // ignore: cast_nullable_to_non_nullable + as String?, + state: freezed == state + ? _value.state + : state // ignore: cast_nullable_to_non_nullable + as String?, + codeChallengeMethod: null == codeChallengeMethod + ? _value.codeChallengeMethod + : codeChallengeMethod // ignore: cast_nullable_to_non_nullable + as String, + codeVerifier: freezed == codeVerifier + ? _value.codeVerifier + : codeVerifier // ignore: cast_nullable_to_non_nullable + as String?, + codeChallenge: freezed == codeChallenge + ? _value.codeChallenge + : codeChallenge // ignore: cast_nullable_to_non_nullable + as String?, + username: freezed == username + ? _value.username + : username // ignore: cast_nullable_to_non_nullable + as String?, + password: freezed == password + ? _value.password + : password // ignore: cast_nullable_to_non_nullable + as String?, + refreshToken: freezed == refreshToken + ? _value.refreshToken + : refreshToken // ignore: cast_nullable_to_non_nullable + as String?, + identityToken: freezed == identityToken + ? _value.identityToken + : identityToken // ignore: cast_nullable_to_non_nullable + as String?, + accessToken: freezed == accessToken + ? _value.accessToken + : accessToken // ignore: cast_nullable_to_non_nullable + as String?, + ) + as $Val, + ); + } +} + +/// @nodoc +abstract class _$$AuthOAuth2ModelImplCopyWith<$Res> + implements $AuthOAuth2ModelCopyWith<$Res> { + factory _$$AuthOAuth2ModelImplCopyWith( + _$AuthOAuth2ModelImpl value, + $Res Function(_$AuthOAuth2ModelImpl) then, + ) = __$$AuthOAuth2ModelImplCopyWithImpl<$Res>; + @override + @useResult + $Res call({ + OAuth2GrantType grantType, + String authorizationUrl, + String accessTokenUrl, + String clientId, + String clientSecret, + String? credentialsFilePath, + String? redirectUrl, + String? scope, + String? state, + String codeChallengeMethod, + String? codeVerifier, + String? codeChallenge, + String? username, + String? password, + String? refreshToken, + String? identityToken, + String? accessToken, + }); +} + +/// @nodoc +class __$$AuthOAuth2ModelImplCopyWithImpl<$Res> + extends _$AuthOAuth2ModelCopyWithImpl<$Res, _$AuthOAuth2ModelImpl> + implements _$$AuthOAuth2ModelImplCopyWith<$Res> { + __$$AuthOAuth2ModelImplCopyWithImpl( + _$AuthOAuth2ModelImpl _value, + $Res Function(_$AuthOAuth2ModelImpl) _then, + ) : super(_value, _then); + + /// Create a copy of AuthOAuth2Model + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? grantType = null, + Object? authorizationUrl = null, + Object? accessTokenUrl = null, + Object? clientId = null, + Object? clientSecret = null, + Object? credentialsFilePath = freezed, + Object? redirectUrl = freezed, + Object? scope = freezed, + Object? state = freezed, + Object? codeChallengeMethod = null, + Object? codeVerifier = freezed, + Object? codeChallenge = freezed, + Object? username = freezed, + Object? password = freezed, + Object? refreshToken = freezed, + Object? identityToken = freezed, + Object? accessToken = freezed, + }) { + return _then( + _$AuthOAuth2ModelImpl( + grantType: null == grantType + ? _value.grantType + : grantType // ignore: cast_nullable_to_non_nullable + as OAuth2GrantType, + authorizationUrl: null == authorizationUrl + ? _value.authorizationUrl + : authorizationUrl // ignore: cast_nullable_to_non_nullable + as String, + accessTokenUrl: null == accessTokenUrl + ? _value.accessTokenUrl + : accessTokenUrl // ignore: cast_nullable_to_non_nullable + as String, + clientId: null == clientId + ? _value.clientId + : clientId // ignore: cast_nullable_to_non_nullable + as String, + clientSecret: null == clientSecret + ? _value.clientSecret + : clientSecret // ignore: cast_nullable_to_non_nullable + as String, + credentialsFilePath: freezed == credentialsFilePath + ? _value.credentialsFilePath + : credentialsFilePath // ignore: cast_nullable_to_non_nullable + as String?, + redirectUrl: freezed == redirectUrl + ? _value.redirectUrl + : redirectUrl // ignore: cast_nullable_to_non_nullable + as String?, + scope: freezed == scope + ? _value.scope + : scope // ignore: cast_nullable_to_non_nullable + as String?, + state: freezed == state + ? _value.state + : state // ignore: cast_nullable_to_non_nullable + as String?, + codeChallengeMethod: null == codeChallengeMethod + ? _value.codeChallengeMethod + : codeChallengeMethod // ignore: cast_nullable_to_non_nullable + as String, + codeVerifier: freezed == codeVerifier + ? _value.codeVerifier + : codeVerifier // ignore: cast_nullable_to_non_nullable + as String?, + codeChallenge: freezed == codeChallenge + ? _value.codeChallenge + : codeChallenge // ignore: cast_nullable_to_non_nullable + as String?, + username: freezed == username + ? _value.username + : username // ignore: cast_nullable_to_non_nullable + as String?, + password: freezed == password + ? _value.password + : password // ignore: cast_nullable_to_non_nullable + as String?, + refreshToken: freezed == refreshToken + ? _value.refreshToken + : refreshToken // ignore: cast_nullable_to_non_nullable + as String?, + identityToken: freezed == identityToken + ? _value.identityToken + : identityToken // ignore: cast_nullable_to_non_nullable + as String?, + accessToken: freezed == accessToken + ? _value.accessToken + : accessToken // ignore: cast_nullable_to_non_nullable + as String?, + ), + ); + } +} + +/// @nodoc +@JsonSerializable() +class _$AuthOAuth2ModelImpl implements _AuthOAuth2Model { + const _$AuthOAuth2ModelImpl({ + this.grantType = OAuth2GrantType.authorizationCode, + required this.authorizationUrl, + required this.accessTokenUrl, + required this.clientId, + required this.clientSecret, + this.credentialsFilePath, + this.redirectUrl, + this.scope, + this.state, + this.codeChallengeMethod = "sha-256", + this.codeVerifier, + this.codeChallenge, + this.username, + this.password, + this.refreshToken, + this.identityToken, + this.accessToken, + }); + + factory _$AuthOAuth2ModelImpl.fromJson(Map json) => + _$$AuthOAuth2ModelImplFromJson(json); + + @override + @JsonKey() + final OAuth2GrantType grantType; + @override + final String authorizationUrl; + @override + final String accessTokenUrl; + @override + final String clientId; + @override + final String clientSecret; + @override + final String? credentialsFilePath; + @override + final String? redirectUrl; + @override + final String? scope; + @override + final String? state; + @override + @JsonKey() + final String codeChallengeMethod; + @override + final String? codeVerifier; + @override + final String? codeChallenge; + @override + final String? username; + @override + final String? password; + @override + final String? refreshToken; + @override + final String? identityToken; + @override + final String? accessToken; + + @override + String toString() { + return 'AuthOAuth2Model(grantType: $grantType, authorizationUrl: $authorizationUrl, accessTokenUrl: $accessTokenUrl, clientId: $clientId, clientSecret: $clientSecret, credentialsFilePath: $credentialsFilePath, redirectUrl: $redirectUrl, scope: $scope, state: $state, codeChallengeMethod: $codeChallengeMethod, codeVerifier: $codeVerifier, codeChallenge: $codeChallenge, username: $username, password: $password, refreshToken: $refreshToken, identityToken: $identityToken, accessToken: $accessToken)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$AuthOAuth2ModelImpl && + (identical(other.grantType, grantType) || + other.grantType == grantType) && + (identical(other.authorizationUrl, authorizationUrl) || + other.authorizationUrl == authorizationUrl) && + (identical(other.accessTokenUrl, accessTokenUrl) || + other.accessTokenUrl == accessTokenUrl) && + (identical(other.clientId, clientId) || + other.clientId == clientId) && + (identical(other.clientSecret, clientSecret) || + other.clientSecret == clientSecret) && + (identical(other.credentialsFilePath, credentialsFilePath) || + other.credentialsFilePath == credentialsFilePath) && + (identical(other.redirectUrl, redirectUrl) || + other.redirectUrl == redirectUrl) && + (identical(other.scope, scope) || other.scope == scope) && + (identical(other.state, state) || other.state == state) && + (identical(other.codeChallengeMethod, codeChallengeMethod) || + other.codeChallengeMethod == codeChallengeMethod) && + (identical(other.codeVerifier, codeVerifier) || + other.codeVerifier == codeVerifier) && + (identical(other.codeChallenge, codeChallenge) || + other.codeChallenge == codeChallenge) && + (identical(other.username, username) || + other.username == username) && + (identical(other.password, password) || + other.password == password) && + (identical(other.refreshToken, refreshToken) || + other.refreshToken == refreshToken) && + (identical(other.identityToken, identityToken) || + other.identityToken == identityToken) && + (identical(other.accessToken, accessToken) || + other.accessToken == accessToken)); + } + + @JsonKey(includeFromJson: false, includeToJson: false) + @override + int get hashCode => Object.hash( + runtimeType, + grantType, + authorizationUrl, + accessTokenUrl, + clientId, + clientSecret, + credentialsFilePath, + redirectUrl, + scope, + state, + codeChallengeMethod, + codeVerifier, + codeChallenge, + username, + password, + refreshToken, + identityToken, + accessToken, + ); + + /// Create a copy of AuthOAuth2Model + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @override + @pragma('vm:prefer-inline') + _$$AuthOAuth2ModelImplCopyWith<_$AuthOAuth2ModelImpl> get copyWith => + __$$AuthOAuth2ModelImplCopyWithImpl<_$AuthOAuth2ModelImpl>( + this, + _$identity, + ); + + @override + Map toJson() { + return _$$AuthOAuth2ModelImplToJson(this); + } +} + +abstract class _AuthOAuth2Model implements AuthOAuth2Model { + const factory _AuthOAuth2Model({ + final OAuth2GrantType grantType, + required final String authorizationUrl, + required final String accessTokenUrl, + required final String clientId, + required final String clientSecret, + final String? credentialsFilePath, + final String? redirectUrl, + final String? scope, + final String? state, + final String codeChallengeMethod, + final String? codeVerifier, + final String? codeChallenge, + final String? username, + final String? password, + final String? refreshToken, + final String? identityToken, + final String? accessToken, + }) = _$AuthOAuth2ModelImpl; + + factory _AuthOAuth2Model.fromJson(Map json) = + _$AuthOAuth2ModelImpl.fromJson; + + @override + OAuth2GrantType get grantType; + @override + String get authorizationUrl; + @override + String get accessTokenUrl; + @override + String get clientId; + @override + String get clientSecret; + @override + String? get credentialsFilePath; + @override + String? get redirectUrl; + @override + String? get scope; + @override + String? get state; + @override + String get codeChallengeMethod; + @override + String? get codeVerifier; + @override + String? get codeChallenge; + @override + String? get username; + @override + String? get password; + @override + String? get refreshToken; + @override + String? get identityToken; + @override + String? get accessToken; + + /// Create a copy of AuthOAuth2Model + /// with the given fields replaced by the non-null parameter values. + @override + @JsonKey(includeFromJson: false, includeToJson: false) + _$$AuthOAuth2ModelImplCopyWith<_$AuthOAuth2ModelImpl> get copyWith => + throw _privateConstructorUsedError; +} diff --git a/packages/better_networking/lib/models/auth/auth_oauth2_model.g.dart b/packages/better_networking/lib/models/auth/auth_oauth2_model.g.dart new file mode 100644 index 000000000..68515dd89 --- /dev/null +++ b/packages/better_networking/lib/models/auth/auth_oauth2_model.g.dart @@ -0,0 +1,59 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'auth_oauth2_model.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_$AuthOAuth2ModelImpl _$$AuthOAuth2ModelImplFromJson( + Map json, +) => _$AuthOAuth2ModelImpl( + grantType: + $enumDecodeNullable(_$OAuth2GrantTypeEnumMap, json['grantType']) ?? + OAuth2GrantType.authorizationCode, + authorizationUrl: json['authorizationUrl'] as String, + accessTokenUrl: json['accessTokenUrl'] as String, + clientId: json['clientId'] as String, + clientSecret: json['clientSecret'] as String, + credentialsFilePath: json['credentialsFilePath'] as String?, + redirectUrl: json['redirectUrl'] as String?, + scope: json['scope'] as String?, + state: json['state'] as String?, + codeChallengeMethod: json['codeChallengeMethod'] as String? ?? "sha-256", + codeVerifier: json['codeVerifier'] as String?, + codeChallenge: json['codeChallenge'] as String?, + username: json['username'] as String?, + password: json['password'] as String?, + refreshToken: json['refreshToken'] as String?, + identityToken: json['identityToken'] as String?, + accessToken: json['accessToken'] as String?, +); + +Map _$$AuthOAuth2ModelImplToJson( + _$AuthOAuth2ModelImpl instance, +) => { + 'grantType': _$OAuth2GrantTypeEnumMap[instance.grantType]!, + 'authorizationUrl': instance.authorizationUrl, + 'accessTokenUrl': instance.accessTokenUrl, + 'clientId': instance.clientId, + 'clientSecret': instance.clientSecret, + 'credentialsFilePath': instance.credentialsFilePath, + 'redirectUrl': instance.redirectUrl, + 'scope': instance.scope, + 'state': instance.state, + 'codeChallengeMethod': instance.codeChallengeMethod, + 'codeVerifier': instance.codeVerifier, + 'codeChallenge': instance.codeChallenge, + 'username': instance.username, + 'password': instance.password, + 'refreshToken': instance.refreshToken, + 'identityToken': instance.identityToken, + 'accessToken': instance.accessToken, +}; + +const _$OAuth2GrantTypeEnumMap = { + OAuth2GrantType.authorizationCode: 'authorizationCode', + OAuth2GrantType.clientCredentials: 'clientCredentials', + OAuth2GrantType.resourceOwnerPassword: 'resourceOwnerPassword', +}; diff --git a/packages/better_networking/lib/models/models.dart b/packages/better_networking/lib/models/models.dart index 2987393d9..5974ac9be 100644 --- a/packages/better_networking/lib/models/models.dart +++ b/packages/better_networking/lib/models/models.dart @@ -6,3 +6,5 @@ export 'auth/auth_basic_model.dart'; export 'auth/auth_bearer_model.dart'; export 'auth/auth_jwt_model.dart'; export 'auth/auth_digest_model.dart'; +export 'auth/auth_oauth2_model.dart'; +export 'auth/auth_oauth1_model.dart'; diff --git a/packages/better_networking/lib/services/http_client_manager.dart b/packages/better_networking/lib/services/http_client_manager.dart index 3d8430c5f..3dbc1a09b 100644 --- a/packages/better_networking/lib/services/http_client_manager.dart +++ b/packages/better_networking/lib/services/http_client_manager.dart @@ -10,6 +10,23 @@ http.Client createHttpClientWithNoSSL() { return IOClient(ioClient); } +class _JsonAcceptClient extends http.BaseClient { + final http.Client _inner; + + _JsonAcceptClient(this._inner); + + @override + Future send(http.BaseRequest request) { + request.headers['Accept'] = 'application/json'; + return _inner.send(request); + } + + @override + void close() { + _inner.close(); + } +} + class HttpClientManager { static final HttpClientManager _instance = HttpClientManager._internal(); static const int _maxCancelledRequests = 100; @@ -60,4 +77,17 @@ class HttpClientManager { bool hasActiveClient(String requestId) { return _clients.containsKey(requestId); } + + http.Client createClientWithJsonAccept( + String requestId, { + bool noSSL = false, + }) { + final baseClient = (noSSL && !kIsWeb) + ? createHttpClientWithNoSSL() + : http.Client(); + + final client = _JsonAcceptClient(baseClient); + _clients[requestId] = client; + return client; + } } diff --git a/packages/better_networking/lib/services/oauth_callback_server.dart b/packages/better_networking/lib/services/oauth_callback_server.dart new file mode 100644 index 000000000..163dfb191 --- /dev/null +++ b/packages/better_networking/lib/services/oauth_callback_server.dart @@ -0,0 +1,373 @@ +import 'dart:io'; +import 'dart:async'; +import 'dart:developer' show log; + +/// A lightweight HTTP server for handling OAuth callbacks on desktop platforms. +/// This provides a standard localhost callback URL that's compatible with most OAuth providers. +class OAuthCallbackServer { + HttpServer? _server; + late int _port; + String? _path; + final Completer _completer = Completer(); + Timer? _timeoutTimer; + bool _isCancelled = false; + + /// Starts the HTTP server and returns the callback URL. + /// + /// [path] - Optional path for the callback endpoint (defaults to '/callback') + /// Returns the full callback URL (e.g., 'http://localhost:8080/callback') + Future start({String path = '/callback'}) async { + _path = path; + + // Try to bind to a random available port starting from 8080 + for (int port = 8080; port <= 8090; port++) { + try { + _server = await HttpServer.bind(InternetAddress.loopbackIPv4, port); + _port = port; + break; + } catch (e) { + // Port is busy, try next one + if (port == 8090) { + throw Exception( + 'Unable to find available port for OAuth callback server', + ); + } + } + } + + if (_server == null) { + throw Exception('Failed to start OAuth callback server'); + } + + _server!.listen(_handleRequest); + + final callbackUrl = 'http://localhost:$_port$_path'; + log('OAuth callback server started at: $callbackUrl'); + + return callbackUrl; + } + + /// Waits for the OAuth callback and returns the full callback URL with query parameters. + /// + /// [timeout] - Optional timeout duration (defaults to 3 minutes) + /// Throws [TimeoutException] if no callback is received within the timeout period. + /// Throws [Exception] if the OAuth flow was manually cancelled. + Future waitForCallback({ + Duration timeout = const Duration(minutes: 3), + }) async { + // Check if already cancelled before starting + if (_isCancelled) { + throw Exception('OAuth flow was cancelled'); + } + + // Set up timeout timer + _timeoutTimer = Timer(timeout, () { + if (!_completer.isCompleted && !_isCancelled) { + _completer.completeError( + TimeoutException( + 'OAuth callback timeout: No response received within ${timeout.inMinutes} minutes. ' + 'You can manually cancel this operation or wait for completion.', + timeout, + ), + ); + + // Automatically stop the server on timeout + _stopServerOnError( + 'OAuth flow timed out after ${timeout.inMinutes} minutes', + ); + } + }); + + try { + return await _completer.future; + } finally { + _timeoutTimer?.cancel(); + _timeoutTimer = null; + } + } + + /// Stops the HTTP server and cleans up resources. + Future stop() async { + if (_server == null) { + log('OAuth callback server already stopped'); + return; + } + + _timeoutTimer?.cancel(); + _timeoutTimer = null; + + try { + await _server?.close(); + log('OAuth callback server stopped gracefully'); + } catch (e) { + log('Error during graceful server stop: $e'); + } finally { + _server = null; + } + } + + /// Cancels the waiting callback operation. + /// This is useful when the user wants to cancel the OAuth flow manually. + void cancel([String reason = 'OAuth flow cancelled by user']) { + _isCancelled = true; + _timeoutTimer?.cancel(); + _timeoutTimer = null; + + if (!_completer.isCompleted) { + _completer.completeError(Exception('OAuth callback cancelled: $reason')); + } + + // Automatically stop the server when cancelled + _stopServerOnError(reason); + } + + /// Checks if the OAuth flow was cancelled + bool get isCancelled => _isCancelled; + + /// Stops the server immediately due to an error condition + /// This is used for automatic cleanup when errors occur + void _stopServerOnError(String reason) { + if (_server == null) { + log('OAuth callback server already stopped, skipping error stop'); + return; + } + + log('Stopping OAuth callback server due to error: $reason'); + + // Cancel any active timers + _timeoutTimer?.cancel(); + _timeoutTimer = null; + + // Close the server without waiting + _server + ?.close(force: true) + .then((_) { + log('OAuth callback server forcefully stopped due to error'); + }) + .catchError((error) { + log('Error while force-stopping server: $error'); + }); + + _server = null; + } + + void _handleRequest(HttpRequest request) { + log('OAuth request received: ${request.uri}'); + + try { + // Handle OPTIONS preflight requests for CORS + if (request.method == 'OPTIONS') { + request.response + ..statusCode = HttpStatus.ok + ..headers.add('Access-Control-Allow-Origin', '*') + ..headers.add('Access-Control-Allow-Methods', 'GET, POST, OPTIONS') + ..headers.add('Access-Control-Allow-Headers', 'Content-Type') + ..close(); + return; + } + + // Check if this is an authorization callback (has 'code' or 'error' parameters) + final uri = request.uri; + final hasCode = uri.queryParameters.containsKey('code'); + final hasError = uri.queryParameters.containsKey('error'); + + String responseHtml; + + if (hasError) { + final error = uri.queryParameters['error'] ?? 'unknown_error'; + final errorDescription = + uri.queryParameters['error_description'] ?? + 'No description provided'; + responseHtml = _generateErrorHtml(error, errorDescription); + + // Complete the future with error and stop server + if (!_completer.isCompleted) { + _completer.completeError( + Exception('OAuth authorization failed: $error - $errorDescription'), + ); + } + + // Send response to browser first, then stop server after a delay + request.response + ..statusCode = HttpStatus.ok + ..headers.contentType = ContentType.html + ..write(responseHtml) + ..close(); + + // Stop server after sending response (with a small delay) + Timer(const Duration(seconds: 1), () { + _stopServerOnError('OAuth authorization error: $error'); + }); + + return; + } else if (hasCode) { + responseHtml = _generateSuccessHtml(); + } else { + responseHtml = _generateInfoHtml(); + } + + // Send response to the browser + request.response + ..statusCode = HttpStatus.ok + ..headers.contentType = ContentType.html + ..write(responseHtml) + ..close(); + + // Complete the future with the full callback URL + if (!_completer.isCompleted) { + _completer.complete(request.uri.toString()); + } + + // For successful authorization, schedule server stop after response + if (hasCode) { + Timer(const Duration(seconds: 6), () { + _stopServerOnError('OAuth flow completed successfully'); + }); + } + } catch (e) { + log('Error handling OAuth callback request: $e'); + + // Complete with error if not already completed + if (!_completer.isCompleted) { + _completer.completeError( + Exception('OAuth callback request handling failed: $e'), + ); + } + + // Try to send an error response + try { + request.response + ..statusCode = HttpStatus.internalServerError + ..write('Internal server error occurred during OAuth callback') + ..close(); + } catch (responseError) { + log('Failed to send error response: $responseError'); + } + + // Stop server due to error + _stopServerOnError('Request handling error: $e'); + } + } + + String _generateSuccessHtml() { + return ''' + + + + OAuth Authentication Successful + + + +
+
+
Authentication Successful!
+
+ Your OAuth authorization was completed successfully. + You can now close this window and return to API Dash. +
+
This window will close automatically in 5 seconds...
+
+ + + + '''; + } + + String _generateErrorHtml(String error, String errorDescription) { + return ''' + + + + OAuth Authentication Failed + + + +
+
+
Authentication Failed
+
+ The OAuth authorization was not completed successfully. + Please try again from API Dash. +
+
+ Error: $error
+ Description: $errorDescription +
+
This window will close automatically in 10 seconds...
+
+ + + + '''; + } + + String _generateInfoHtml() { + return ''' + + + + OAuth Callback Server + + + +
+
+
OAuth Callback Server
+
+ This is the OAuth callback endpoint for API Dash. + If you're seeing this page, the callback server is running correctly. + Please return to API Dash to complete the OAuth flow. +
+
+ + + '''; + } +} diff --git a/packages/better_networking/lib/services/services.dart b/packages/better_networking/lib/services/services.dart index d155e9c70..6ae0ee19d 100644 --- a/packages/better_networking/lib/services/services.dart +++ b/packages/better_networking/lib/services/services.dart @@ -1,2 +1,3 @@ export 'http_client_manager.dart'; export 'http_service.dart'; +export 'oauth_callback_server.dart'; diff --git a/packages/better_networking/lib/utils/auth/handle_auth.dart b/packages/better_networking/lib/utils/auth/handle_auth.dart index ffd096865..d4f385785 100644 --- a/packages/better_networking/lib/utils/auth/handle_auth.dart +++ b/packages/better_networking/lib/utils/auth/handle_auth.dart @@ -1,8 +1,13 @@ import 'dart:convert'; +import 'dart:io'; import 'dart:math'; import 'package:better_networking/utils/auth/jwt_auth_utils.dart'; import 'package:better_networking/utils/auth/digest_auth_utils.dart'; import 'package:better_networking/better_networking.dart'; +import 'package:better_networking/utils/auth/oauth2_utils.dart'; +import 'package:flutter/foundation.dart'; + +import 'oauth1_utils.dart'; Future handleAuth( HttpRequestModel httpRequestModel, @@ -106,9 +111,10 @@ Future handleAuth( ); updatedHeaderEnabledList.add(true); } else { - final httpResult = await sendHttpRequest( + final httpResult = await sendHttpRequestV1( "digest-${Random.secure()}", APIType.rest, + null, httpRequestModel, ); final httpResponse = httpResult.$1; @@ -153,11 +159,115 @@ Future handleAuth( } break; case APIAuthType.oauth1: - // TODO: Handle this case. - throw UnimplementedError(); + if (authData.oauth1 != null) { + final oauth1Model = authData.oauth1!; + + try { + final client = generateOAuth1AuthHeader( + oauth1Model, + httpRequestModel, + ); + + updatedHeaders.add( + NameValueModel(name: 'Authorization', value: client), + ); + updatedHeaderEnabledList.add(true); + } catch (e) { + throw Exception('OAuth 1.0 authentication failed: $e'); + } + } + break; case APIAuthType.oauth2: - // TODO: Handle this case. - throw UnimplementedError(); + final oauth2 = authData.oauth2; + + if (oauth2 == null) { + throw Exception("Failed to get OAuth2 Data"); + } + + if (oauth2.redirectUrl == null) { + throw Exception("No Redirect URL found!"); + } + + final credentialsFile = oauth2.credentialsFilePath != null + ? File(oauth2.credentialsFilePath!) + : null; + + switch (oauth2.grantType) { + case OAuth2GrantType.authorizationCode: + // Use localhost callback server for desktop platforms, fallback to custom scheme for mobile + final res = await oAuth2AuthorizationCodeGrant( + identifier: oauth2.clientId, + secret: oauth2.clientSecret, + authorizationEndpoint: Uri.parse(oauth2.authorizationUrl), + redirectUrl: Uri.parse( + oauth2.redirectUrl!.isEmpty + ? "apidash://oauth2/callback" + : oauth2.redirectUrl!, + ), + tokenEndpoint: Uri.parse(oauth2.accessTokenUrl), + credentialsFile: credentialsFile, + scope: oauth2.scope, + ); + + // Clean up the callback server if it exists and is still running + // Note: The server might have already stopped itself due to timeout/error/completion + final server = res.$2; + if (server != null) { + try { + await server.stop(); + } catch (e) { + debugPrint( + 'Error stopping OAuth callback server (might already be stopped): $e', + ); + } + } + + debugPrint(res.$1.credentials.accessToken); + + // Add the access token to the request headers + updatedHeaders.add( + NameValueModel( + name: 'Authorization', + value: 'Bearer ${res.$1.credentials.accessToken}', + ), + ); + updatedHeaderEnabledList.add(true); + + break; + case OAuth2GrantType.clientCredentials: + final client = await oAuth2ClientCredentialsGrantHandler( + oauth2Model: oauth2, + credentialsFile: credentialsFile, + ); + debugPrint(client.credentials.accessToken); + + // Add the access token to the request headers + updatedHeaders.add( + NameValueModel( + name: 'Authorization', + value: 'Bearer ${client.credentials.accessToken}', + ), + ); + updatedHeaderEnabledList.add(true); + break; + case OAuth2GrantType.resourceOwnerPassword: + debugPrint("==Resource Owner Password=="); + final client = await oAuth2ResourceOwnerPasswordGrantHandler( + oauth2Model: oauth2, + credentialsFile: credentialsFile, + ); + debugPrint(client.credentials.accessToken); + + // Add the access token to the request headers + updatedHeaders.add( + NameValueModel( + name: 'Authorization', + value: 'Bearer ${client.credentials.accessToken}', + ), + ); + updatedHeaderEnabledList.add(true); + break; + } } return httpRequestModel.copyWith( diff --git a/packages/better_networking/lib/utils/auth/oauth1_utils.dart b/packages/better_networking/lib/utils/auth/oauth1_utils.dart new file mode 100644 index 000000000..e634ae12a --- /dev/null +++ b/packages/better_networking/lib/utils/auth/oauth1_utils.dart @@ -0,0 +1,161 @@ +import 'dart:convert'; +import 'dart:math' as math; +import 'package:crypto/crypto.dart'; +import '../../models/models.dart'; +import '../../consts.dart'; + +/// Generates a simple OAuth 1.0a Authorization header directly from AuthOAuth1Model model +/// +/// This function supports two OAuth 1.0a signature methods: +/// - HMAC-SHA1: Most commonly used, requires consumer secret and optional token secret +/// - Plaintext: Simple concatenation, only use over HTTPS +/// +/// The function automatically: +/// - Generates timestamp and nonce +/// - Creates the signature base string +/// - Signs using the specified method +/// - Formats the Authorization header +String generateOAuth1AuthHeader( + AuthOAuth1Model oauth1Model, + HttpRequestModel request, +) { + // Generate OAuth parameters + final timestamp = (DateTime.now().millisecondsSinceEpoch ~/ 1000).toString(); + final nonce = _generateNonce(); + + // Build OAuth parameters map + final oauthParams = { + 'oauth_consumer_key': oauth1Model.consumerKey, + 'oauth_signature_method': oauth1Model.signatureMethod.displayType + .toUpperCase(), + 'oauth_timestamp': timestamp, + 'oauth_nonce': nonce, + 'oauth_version': oauth1Model.version, + }; + + // Add oauth_token if available + if (oauth1Model.accessToken != null && oauth1Model.accessToken!.isNotEmpty) { + oauthParams['oauth_token'] = oauth1Model.accessToken!; + } + + // Create signature base string and signing key + final method = request.method.name.toUpperCase(); + final uri = Uri.parse(request.url); + final baseString = _createSignatureBaseString(method, uri, oauthParams); + + // Generate signature based on signature method + final signature = _generateSignature( + oauth1Model.signatureMethod, + baseString, + oauth1Model, + ); + oauthParams['oauth_signature'] = signature; + + // Build Authorization header + final authParts = oauthParams.entries + .map((e) => '${e.key}="${Uri.encodeComponent(e.value)}"') + .join(','); + + return 'OAuth $authParts'; +} + +/// Generates a random nonce for OAuth 1.0a +String _generateNonce() { + const chars = + 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; + final random = math.Random.secure(); + return String.fromCharCodes( + Iterable.generate( + 16, + (_) => chars.codeUnitAt(random.nextInt(chars.length)), + ), + ); +} + +/// Percent-encodes the [param] following RFC 5849. +/// +/// All characters except uppercase and lowercase letters, digits and the +/// characters `-_.~` are percent-encoded. +/// +/// See https://oauth.net/core/1.0a/#encoding_parameters. +String _encodeParam(String param) { + return Uri.encodeComponent(param) + .replaceAll('!', '%21') + .replaceAll('*', '%2A') + .replaceAll("'", '%27') + .replaceAll('(', '%28') + .replaceAll(')', '%29'); +} + +/// Creates the signature base string for OAuth 1.0a +String _createSignatureBaseString( + String method, + Uri uri, + Map parameters, +) { + // 1. Percent encode every key and value that will be signed + final Map encodedParams = {}; + + // Encode OAuth parameters + parameters.forEach((String k, String v) { + encodedParams[_encodeParam(k)] = _encodeParam(v); + }); + + // Add and encode query parameters from the URI + uri.queryParameters.forEach((String k, String v) { + encodedParams[_encodeParam(k)] = _encodeParam(v); + }); + + // Remove 'realm' parameter if present (not included in signature) + encodedParams.remove('realm'); + + // 2. Sort the list of parameters alphabetically by encoded key + final List sortedEncodedKeys = encodedParams.keys.toList()..sort(); + + // 3-7. Create parameter string + final String baseParams = sortedEncodedKeys + .map((String k) { + return '$k=${encodedParams[k]}'; + }) + .join('&'); + + // Create base URI (origin + path) + final baseUri = uri.origin + uri.path; + + // Create signature base string + return '${method.toUpperCase()}&' + '${Uri.encodeComponent(baseUri)}&' + '${Uri.encodeComponent(baseParams)}'; +} + +/// Generates signature based on the specified signature method +/// +/// Supports three OAuth 1.0a signature methods: +/// - HMAC-SHA1: Creates HMAC signature using SHA-1 hash (recommended) +/// - Plaintext: Returns the signing key directly (use only over HTTPS) +String _generateSignature( + OAuth1SignatureMethod signatureMethod, + String baseString, + AuthOAuth1Model oauth1Model, +) { + switch (signatureMethod) { + case OAuth1SignatureMethod.hmacSha1: + final signingKey = + '${Uri.encodeComponent(oauth1Model.consumerSecret)}&' + '${Uri.encodeComponent(oauth1Model.tokenSecret ?? '')}'; + return _generateHmacSha1Signature(baseString, signingKey); + case OAuth1SignatureMethod.plaintext: + final signingKey = + '${Uri.encodeComponent(oauth1Model.consumerSecret)}&' + '${Uri.encodeComponent(oauth1Model.tokenSecret ?? '')}'; + return signingKey; + } +} + +/// Generates HMAC-SHA1 signature for OAuth 1.0a +String _generateHmacSha1Signature(String text, String key) { + final hmac = Hmac(sha1, utf8.encode(key)); + final digest = hmac.convert(utf8.encode(text)).bytes; + + return base64Encode(digest); +} diff --git a/packages/better_networking/lib/utils/auth/oauth2_utils.dart b/packages/better_networking/lib/utils/auth/oauth2_utils.dart new file mode 100644 index 000000000..1a66df166 --- /dev/null +++ b/packages/better_networking/lib/utils/auth/oauth2_utils.dart @@ -0,0 +1,295 @@ +import 'dart:async'; +import 'dart:io'; +import 'package:flutter_web_auth_2/flutter_web_auth_2.dart'; +import 'package:oauth2/oauth2.dart' as oauth2; + +import '../../models/auth/auth_oauth2_model.dart'; +import '../../services/http_client_manager.dart'; +import '../../services/oauth_callback_server.dart'; +import '../platform_utils.dart'; + +/// Advanced OAuth2 authorization code grant handler that returns both the client and server +/// for cases where you need manual control over the callback server lifecycle. +/// +/// Returns a tuple of (oauth2.Client, OAuthCallbackServer?) where the server is null for mobile platforms. +/// The server should be stopped by the caller to clean up resources. +Future<(oauth2.Client, OAuthCallbackServer?)> oAuth2AuthorizationCodeGrant({ + required String identifier, + required String secret, + required Uri authorizationEndpoint, + required Uri tokenEndpoint, + required Uri redirectUrl, + required File? credentialsFile, + String? state, + String? scope, +}) async { + // Check for existing credentials first + if (credentialsFile != null && await credentialsFile.exists()) { + try { + final json = await credentialsFile.readAsString(); + final credentials = oauth2.Credentials.fromJson(json); + + if (credentials.accessToken.isNotEmpty && !credentials.isExpired) { + return ( + oauth2.Client(credentials, identifier: identifier, secret: secret), + null, + ); + } + } catch (e) { + // Ignore credential reading errors and continue with fresh authentication + } + } + + // Create a unique request ID for this OAuth flow + final requestId = 'oauth2-${DateTime.now().millisecondsSinceEpoch}'; + final httpClientManager = HttpClientManager(); + final baseClient = httpClientManager.createClientWithJsonAccept(requestId); + + OAuthCallbackServer? callbackServer; + Uri actualRedirectUrl = redirectUrl; + + try { + // Use localhost callback server for desktop platforms + if (PlatformUtils.shouldUseLocalhostCallback) { + callbackServer = OAuthCallbackServer(); + final localhostUrl = await callbackServer.start(); + actualRedirectUrl = Uri.parse(localhostUrl); + } + + final grant = oauth2.AuthorizationCodeGrant( + identifier, + authorizationEndpoint, + tokenEndpoint, + secret: secret, + httpClient: baseClient, + ); + + final authorizationUrl = grant.getAuthorizationUrl( + actualRedirectUrl, + scopes: scope != null ? [scope] : null, + state: state, + ); + + String callbackUri; + + if (PlatformUtils.shouldUseLocalhostCallback && callbackServer != null) { + // For desktop: Open the authorization URL in the default browser + // and wait for the callback on the localhost server with a 3-minute timeout + await _openUrlInBrowser(authorizationUrl.toString()); + + try { + callbackUri = await callbackServer.waitForCallback( + timeout: const Duration(minutes: 3), + ); + // Convert the relative callback to full URL + callbackUri = + 'http://localhost${Uri.parse(callbackUri).path}${Uri.parse(callbackUri).query.isNotEmpty ? '?${Uri.parse(callbackUri).query}' : ''}'; + } on TimeoutException { + throw Exception( + 'OAuth authorization timed out after 3 minutes. ' + 'Please try again and complete the authorization in your browser. ' + 'If you closed the browser tab, please restart the OAuth flow.', + ); + } catch (e) { + // Handle custom exceptions like browser tab closure + final errorMessage = e.toString(); + if (errorMessage.contains('Browser tab was closed')) { + throw Exception( + 'OAuth authorization was cancelled because the browser tab was closed. ' + 'Please try again and complete the authorization process without closing the browser tab.', + ); + } else if (errorMessage.contains('OAuth callback cancelled')) { + throw Exception( + 'OAuth authorization was cancelled. Please try again if you want to complete the authentication.', + ); + } else { + throw Exception('OAuth authorization failed: $errorMessage'); + } + } + } else { + // For mobile: Use the standard flutter_web_auth_2 approach + callbackUri = await FlutterWebAuth2.authenticate( + url: authorizationUrl.toString(), + callbackUrlScheme: actualRedirectUrl.scheme, + options: const FlutterWebAuth2Options( + useWebview: true, + windowName: 'OAuth Authorization - API Dash', + ), + ); + } + + // Parse the callback URI and handle the authorization response + final callbackUriParsed = Uri.parse(callbackUri); + final client = await grant.handleAuthorizationResponse( + callbackUriParsed.queryParameters, + ); + + if (credentialsFile != null) { + await credentialsFile.writeAsString(client.credentials.toJson()); + } + + return (client, callbackServer); + } catch (e) { + // Clean up the callback server immediately on error + if (callbackServer != null) { + try { + await callbackServer.stop(); + } catch (serverError) { + // Ignore server cleanup errors + } + } + // Re-throw the original error + rethrow; + } finally { + // Clean up HTTP client + httpClientManager.closeClient(requestId); + } +} + +Future oAuth2ClientCredentialsGrantHandler({ + required AuthOAuth2Model oauth2Model, + required File? credentialsFile, +}) async { + // Try to use saved credentials + if (credentialsFile != null && await credentialsFile.exists()) { + try { + final json = await credentialsFile.readAsString(); + final credentials = oauth2.Credentials.fromJson(json); + + if (credentials.accessToken.isNotEmpty && !credentials.isExpired) { + return oauth2.Client( + credentials, + identifier: oauth2Model.clientId, + secret: oauth2Model.clientSecret, + ); + } + } catch (e) { + // Ignore credential reading errors and continue with fresh authentication + } + } + + // Create a unique request ID for this OAuth flow + final requestId = 'oauth2-client-${DateTime.now().millisecondsSinceEpoch}'; + final httpClientManager = HttpClientManager(); + final baseClient = httpClientManager.createClientWithJsonAccept(requestId); + + try { + // Otherwise, perform the client credentials grant + final client = await oauth2.clientCredentialsGrant( + Uri.parse(oauth2Model.accessTokenUrl), + oauth2Model.clientId, + oauth2Model.clientSecret, + scopes: oauth2Model.scope != null ? [oauth2Model.scope!] : null, + basicAuth: false, + httpClient: baseClient, + ); + + try { + if (credentialsFile != null) { + await credentialsFile.writeAsString(client.credentials.toJson()); + } + } catch (e) { + // Ignore credential saving errors + } + + // Clean up the HTTP client + httpClientManager.closeClient(requestId); + + return client; + } catch (e) { + // Clean up the HTTP client on error + httpClientManager.closeClient(requestId); + rethrow; + } +} + +Future oAuth2ResourceOwnerPasswordGrantHandler({ + required AuthOAuth2Model oauth2Model, + required File? credentialsFile, +}) async { + // Try to use saved credentials + if (credentialsFile != null && await credentialsFile.exists()) { + try { + final json = await credentialsFile.readAsString(); + final credentials = oauth2.Credentials.fromJson(json); + + if (credentials.accessToken.isNotEmpty && !credentials.isExpired) { + return oauth2.Client( + credentials, + identifier: oauth2Model.clientId, + secret: oauth2Model.clientSecret, + ); + } + } catch (e) { + // Ignore credential reading errors and continue with fresh authentication + } + } + if ((oauth2Model.username == null || oauth2Model.username!.isEmpty) || + (oauth2Model.password == null || oauth2Model.password!.isEmpty)) { + throw Exception("Username or Password cannot be empty"); + } + + // Create a unique request ID for this OAuth flow + final requestId = 'oauth2-password-${DateTime.now().millisecondsSinceEpoch}'; + final httpClientManager = HttpClientManager(); + final baseClient = httpClientManager.createClientWithJsonAccept(requestId); + + try { + // Otherwise, perform the owner password grant + final client = await oauth2.resourceOwnerPasswordGrant( + Uri.parse(oauth2Model.accessTokenUrl), + oauth2Model.username!, + oauth2Model.password!, + identifier: oauth2Model.clientId, + secret: oauth2Model.clientSecret, + scopes: oauth2Model.scope != null ? [oauth2Model.scope!] : null, + basicAuth: false, + httpClient: baseClient, + ); + + try { + if (credentialsFile != null) { + await credentialsFile.writeAsString(client.credentials.toJson()); + } + } catch (e) { + // Ignore credential saving errors + } + + // Clean up the HTTP client + httpClientManager.closeClient(requestId); + + return client; + } catch (e) { + // Clean up the HTTP client on error + httpClientManager.closeClient(requestId); + rethrow; + } +} + +/// Opens a URL in the default system browser. +/// This is used for desktop platforms where we want to open the OAuth authorization URL +/// in the user's default browser and use localhost callback server to capture the response. +Future _openUrlInBrowser(String url) async { + try { + if (PlatformUtils.isDesktop) { + Process? process; + if (Platform.isMacOS) { + process = await Process.start('open', [url]); + } else if (Platform.isWindows) { + process = await Process.start('rundll32', [ + 'url.dll,FileProtocolHandler', + url, + ]); + } else if (Platform.isLinux) { + process = await Process.start('xdg-open', [url]); + } + + if (process != null) { + await process.exitCode; // Wait for the process to complete + } + } + } catch (e) { + // Fallback: throw an exception so the calling code can handle it + throw Exception('Failed to open authorization URL in browser: $e'); + } +} diff --git a/packages/better_networking/lib/utils/platform_utils.dart b/packages/better_networking/lib/utils/platform_utils.dart new file mode 100644 index 000000000..5a6650a72 --- /dev/null +++ b/packages/better_networking/lib/utils/platform_utils.dart @@ -0,0 +1,19 @@ +import 'dart:io'; +import 'package:flutter/foundation.dart'; + +/// Platform detection utilities for the better_networking package. +class PlatformUtils { + /// Returns true if running on desktop platforms (macOS, Windows, Linux). + static bool get isDesktop => + !kIsWeb && (Platform.isMacOS || Platform.isWindows || Platform.isLinux); + + /// Returns true if running on mobile platforms (iOS, Android). + static bool get isMobile => !kIsWeb && (Platform.isIOS || Platform.isAndroid); + + /// Returns true if running on web. + static bool get isWeb => kIsWeb; + + /// Returns true if OAuth should use localhost callback server. + /// This is true for desktop platforms. + static bool get shouldUseLocalhostCallback => isDesktop; +} diff --git a/packages/better_networking/lib/utils/utils.dart b/packages/better_networking/lib/utils/utils.dart index 7857ed81c..ad25219f7 100644 --- a/packages/better_networking/lib/utils/utils.dart +++ b/packages/better_networking/lib/utils/utils.dart @@ -2,6 +2,7 @@ export 'content_type_utils.dart'; export 'graphql_utils.dart'; export 'http_request_utils.dart'; export 'http_response_utils.dart'; +export 'platform_utils.dart'; export 'string_utils.dart' hide RandomStringGenerator; export 'uri_utils.dart'; export 'auth/handle_auth.dart'; diff --git a/packages/better_networking/pubspec.yaml b/packages/better_networking/pubspec.yaml index e51a94bd8..d9b337194 100644 --- a/packages/better_networking/pubspec.yaml +++ b/packages/better_networking/pubspec.yaml @@ -28,6 +28,9 @@ dependencies: json_annotation: ^4.9.0 seed: ^0.0.3 xml: ^6.3.0 + oauth1: ^2.1.0 + oauth2: ^2.0.3 + flutter_web_auth_2: ^5.0.0-alpha.3 dev_dependencies: flutter_test: diff --git a/packages/better_networking/test/models/auth/auth_models.dart b/packages/better_networking/test/models/auth/auth_models.dart index 50990796d..21ab63fef 100644 --- a/packages/better_networking/test/models/auth/auth_models.dart +++ b/packages/better_networking/test/models/auth/auth_models.dart @@ -4,6 +4,8 @@ import 'package:better_networking/models/auth/auth_bearer_model.dart'; import 'package:better_networking/models/auth/auth_api_key_model.dart'; import 'package:better_networking/models/auth/auth_jwt_model.dart'; import 'package:better_networking/models/auth/auth_digest_model.dart'; +import 'package:better_networking/models/auth/auth_oauth1_model.dart'; +import 'package:better_networking/models/auth/auth_oauth2_model.dart'; import 'package:better_networking/consts.dart'; /// Auth models for testing @@ -94,6 +96,64 @@ const authDigestModel2 = AuthDigestModel( opaque: 'fedcba0987654321098765432109876543', ); +const authOAuth1Model1 = AuthOAuth1Model( + consumerKey: 'oauth1-consumer-key-123', + consumerSecret: 'oauth1-consumer-secret-456', + credentialsFilePath: '/path/to/oauth1/credentials.json', + accessToken: 'oauth1-access-token-789', + tokenSecret: 'oauth1-token-secret-012', + signatureMethod: OAuth1SignatureMethod.hmacSha1, + parameterLocation: 'header', + version: '1.0', + realm: 'oauth1-realm', + callbackUrl: 'https://example.com/callback', + verifier: 'oauth1-verifier-345', + nonce: 'oauth1-nonce-678', + timestamp: '1640995200', + includeBodyHash: false, +); + +const authOAuth1Model2 = AuthOAuth1Model( + consumerKey: 'different-consumer-key', + consumerSecret: 'different-consumer-secret', + credentialsFilePath: '/different/path/credentials.json', + signatureMethod: OAuth1SignatureMethod.plaintext, + parameterLocation: 'query', + version: '1.0a', + includeBodyHash: true, +); + +const authOAuth2Model1 = AuthOAuth2Model( + grantType: OAuth2GrantType.authorizationCode, + authorizationUrl: 'https://oauth.example.com/authorize', + accessTokenUrl: 'https://oauth.example.com/token', + clientId: 'oauth2-client-id-123', + clientSecret: 'oauth2-client-secret-456', + credentialsFilePath: '/path/to/oauth2/credentials.json', + redirectUrl: 'https://example.com/redirect', + scope: 'read write admin', + state: 'oauth2-state-789', + codeChallengeMethod: 'S256', + codeVerifier: 'oauth2-code-verifier-012', + codeChallenge: 'oauth2-code-challenge-345', + username: 'oauth2-username', + password: 'oauth2-password', + refreshToken: 'oauth2-refresh-token-678', + identityToken: 'oauth2-identity-token-901', + accessToken: 'oauth2-access-token-234', +); + +const authOAuth2Model2 = AuthOAuth2Model( + grantType: OAuth2GrantType.clientCredentials, + authorizationUrl: 'https://different-oauth.example.com/auth', + accessTokenUrl: 'https://different-oauth.example.com/token', + clientId: 'different-client-id', + clientSecret: 'different-client-secret', + credentialsFilePath: '/different/oauth2/path.json', + scope: 'api:read', + codeChallengeMethod: 'plain', +); + const authModel1 = AuthModel(type: APIAuthType.basic, basic: authBasicModel1); const authModel2 = AuthModel( @@ -113,6 +173,16 @@ const authModel5 = AuthModel( digest: authDigestModel1, ); +const authModel6 = AuthModel( + type: APIAuthType.oauth1, + oauth1: authOAuth1Model1, +); + +const authModel7 = AuthModel( + type: APIAuthType.oauth2, + oauth2: authOAuth2Model1, +); + const authModelNone = AuthModel(type: APIAuthType.none); /// JSON representations for testing @@ -169,6 +239,43 @@ final Map authDigestModelJson1 = { "opaque": "5ccc069c403ebaf9f0171e9517f40e41", }; +final Map authOAuth1ModelJson1 = { + "consumerKey": "oauth1-consumer-key-123", + "consumerSecret": "oauth1-consumer-secret-456", + "credentialsFilePath": "/path/to/oauth1/credentials.json", + "accessToken": "oauth1-access-token-789", + "tokenSecret": "oauth1-token-secret-012", + "signatureMethod": "hmacSha1", + "parameterLocation": "header", + "version": "1.0", + "realm": "oauth1-realm", + "callbackUrl": "https://example.com/callback", + "verifier": "oauth1-verifier-345", + "nonce": "oauth1-nonce-678", + "timestamp": "1640995200", + "includeBodyHash": false, +}; + +final Map authOAuth2ModelJson1 = { + "grantType": "authorizationCode", + "authorizationUrl": "https://oauth.example.com/authorize", + "accessTokenUrl": "https://oauth.example.com/token", + "clientId": "oauth2-client-id-123", + "clientSecret": "oauth2-client-secret-456", + "credentialsFilePath": "/path/to/oauth2/credentials.json", + "redirectUrl": "https://example.com/redirect", + "scope": "read write admin", + "state": "oauth2-state-789", + "codeChallengeMethod": "S256", + "codeVerifier": "oauth2-code-verifier-012", + "codeChallenge": "oauth2-code-challenge-345", + "username": "oauth2-username", + "password": "oauth2-password", + "refreshToken": "oauth2-refresh-token-678", + "identityToken": "oauth2-identity-token-901", + "accessToken": "oauth2-access-token-234", +}; + final Map authModelJson1 = { "type": "basic", "apikey": null, @@ -176,6 +283,8 @@ final Map authModelJson1 = { "basic": {"username": "john_doe", "password": "secure_password"}, "jwt": null, "digest": null, + "oauth1": null, + "oauth2": null, }; final Map authModelJson2 = { @@ -188,6 +297,8 @@ final Map authModelJson2 = { "basic": null, "jwt": null, "digest": null, + "oauth1": null, + "oauth2": null, }; final Map authModelJson3 = { @@ -200,6 +311,63 @@ final Map authModelJson3 = { "basic": null, "jwt": null, "digest": null, + "oauth1": null, + "oauth2": null, +}; + +final Map authModelOAuth1Json = { + "type": "oauth1", + "apikey": null, + "bearer": null, + "basic": null, + "jwt": null, + "digest": null, + "oauth1": { + "consumerKey": "oauth1-consumer-key-123", + "consumerSecret": "oauth1-consumer-secret-456", + "credentialsFilePath": "/path/to/oauth1/credentials.json", + "accessToken": "oauth1-access-token-789", + "tokenSecret": "oauth1-token-secret-012", + "signatureMethod": "hmacSha1", + "parameterLocation": "header", + "version": "1.0", + "realm": "oauth1-realm", + "callbackUrl": "https://example.com/callback", + "verifier": "oauth1-verifier-345", + "nonce": "oauth1-nonce-678", + "timestamp": "1640995200", + "includeBodyHash": false, + }, + "oauth2": null, +}; + +final Map authModelOAuth2Json = { + "type": "oauth2", + "apikey": null, + "bearer": null, + "basic": null, + "jwt": null, + "digest": null, + "oauth1": null, + "oauth2": { + "grantType": "authorizationCode", + "authorizationUrl": "https://oauth.example.com/authorize", + "accessTokenUrl": "https://oauth.example.com/token", + "clientId": "oauth2-client-id-123", + "clientSecret": "oauth2-client-secret-456", + "credentialsFilePath": "/path/to/oauth2/credentials.json", + "redirectUrl": "https://example.com/redirect", + "scope": "read write admin", + "state": "oauth2-state-789", + "codeChallengeMethod": "S256", + "codeVerifier": "oauth2-code-verifier-012", + "codeChallenge": "oauth2-code-challenge-345", + "username": "oauth2-username", + "password": "oauth2-password", + "refreshToken": "oauth2-refresh-token-678", + "identityToken": "oauth2-identity-token-901", + "accessToken": "oauth2-access-token-234", + }, }; final Map authModelNoneJson = { @@ -209,4 +377,6 @@ final Map authModelNoneJson = { "basic": null, "jwt": null, "digest": null, + "oauth1": null, + "oauth2": null, }; diff --git a/packages/better_networking/test/models/auth/auth_oauth1_model_test.dart b/packages/better_networking/test/models/auth/auth_oauth1_model_test.dart new file mode 100644 index 000000000..419da347a --- /dev/null +++ b/packages/better_networking/test/models/auth/auth_oauth1_model_test.dart @@ -0,0 +1,225 @@ +import 'package:better_networking/models/auth/auth_oauth1_model.dart'; +import 'package:better_networking/consts.dart'; +import 'package:test/test.dart'; +import 'auth_models.dart'; + +void main() { + group('Testing AuthOAuth1Model', () { + test("Testing AuthOAuth1Model copyWith", () { + var authOAuth1Model = authOAuth1Model1; + final authOAuth1ModelCopyWith = authOAuth1Model.copyWith( + consumerKey: 'new_consumer_key', + signatureMethod: OAuth1SignatureMethod.plaintext, + parameterLocation: 'query', + includeBodyHash: true, + ); + expect(authOAuth1ModelCopyWith.consumerKey, 'new_consumer_key'); + expect( + authOAuth1ModelCopyWith.signatureMethod, + OAuth1SignatureMethod.plaintext, + ); + expect(authOAuth1ModelCopyWith.parameterLocation, 'query'); + expect(authOAuth1ModelCopyWith.includeBodyHash, true); + // original model unchanged + expect(authOAuth1Model.consumerKey, 'oauth1-consumer-key-123'); + expect(authOAuth1Model.signatureMethod, OAuth1SignatureMethod.hmacSha1); + expect(authOAuth1Model.parameterLocation, 'header'); + expect(authOAuth1Model.includeBodyHash, false); + }); + + test("Testing AuthOAuth1Model toJson", () { + var authOAuth1Model = authOAuth1Model1; + expect(authOAuth1Model.toJson(), authOAuth1ModelJson1); + }); + + test("Testing AuthOAuth1Model fromJson", () { + var authOAuth1Model = authOAuth1Model1; + final modelFromJson = AuthOAuth1Model.fromJson(authOAuth1ModelJson1); + expect(modelFromJson, authOAuth1Model); + expect(modelFromJson.consumerKey, 'oauth1-consumer-key-123'); + expect(modelFromJson.consumerSecret, 'oauth1-consumer-secret-456'); + expect( + modelFromJson.credentialsFilePath, + '/path/to/oauth1/credentials.json', + ); + expect(modelFromJson.accessToken, 'oauth1-access-token-789'); + expect(modelFromJson.tokenSecret, 'oauth1-token-secret-012'); + expect(modelFromJson.signatureMethod, OAuth1SignatureMethod.hmacSha1); + }); + + test("Testing AuthOAuth1Model getters", () { + var authOAuth1Model = authOAuth1Model1; + expect(authOAuth1Model.consumerKey, 'oauth1-consumer-key-123'); + expect(authOAuth1Model.consumerSecret, 'oauth1-consumer-secret-456'); + expect( + authOAuth1Model.credentialsFilePath, + '/path/to/oauth1/credentials.json', + ); + expect(authOAuth1Model.accessToken, 'oauth1-access-token-789'); + expect(authOAuth1Model.tokenSecret, 'oauth1-token-secret-012'); + expect(authOAuth1Model.signatureMethod, OAuth1SignatureMethod.hmacSha1); + expect(authOAuth1Model.parameterLocation, 'header'); + expect(authOAuth1Model.version, '1.0'); + expect(authOAuth1Model.realm, 'oauth1-realm'); + expect(authOAuth1Model.callbackUrl, 'https://example.com/callback'); + expect(authOAuth1Model.verifier, 'oauth1-verifier-345'); + expect(authOAuth1Model.nonce, 'oauth1-nonce-678'); + expect(authOAuth1Model.timestamp, '1640995200'); + expect(authOAuth1Model.includeBodyHash, false); + }); + + test("Testing AuthOAuth1Model equality", () { + const authOAuth1Model1Copy = AuthOAuth1Model( + consumerKey: 'oauth1-consumer-key-123', + consumerSecret: 'oauth1-consumer-secret-456', + credentialsFilePath: '/path/to/oauth1/credentials.json', + accessToken: 'oauth1-access-token-789', + tokenSecret: 'oauth1-token-secret-012', + signatureMethod: OAuth1SignatureMethod.hmacSha1, + parameterLocation: 'header', + version: '1.0', + realm: 'oauth1-realm', + callbackUrl: 'https://example.com/callback', + verifier: 'oauth1-verifier-345', + nonce: 'oauth1-nonce-678', + timestamp: '1640995200', + includeBodyHash: false, + ); + expect(authOAuth1Model1, authOAuth1Model1Copy); + expect(authOAuth1Model1, isNot(authOAuth1Model2)); + }); + + test("Testing AuthOAuth1Model with different values", () { + expect(authOAuth1Model2.consumerKey, 'different-consumer-key'); + expect(authOAuth1Model2.consumerSecret, 'different-consumer-secret'); + expect( + authOAuth1Model2.credentialsFilePath, + '/different/path/credentials.json', + ); + expect(authOAuth1Model2.signatureMethod, OAuth1SignatureMethod.plaintext); + expect(authOAuth1Model2.parameterLocation, 'query'); + expect(authOAuth1Model2.version, '1.0a'); + expect(authOAuth1Model2.includeBodyHash, true); + expect(authOAuth1Model1.consumerKey, isNot(authOAuth1Model2.consumerKey)); + expect( + authOAuth1Model1.signatureMethod, + isNot(authOAuth1Model2.signatureMethod), + ); + }); + + test("Testing AuthOAuth1Model with default values", () { + const authOAuth1ModelDefaults = AuthOAuth1Model( + consumerKey: 'test-consumer-key', + consumerSecret: 'test-consumer-secret', + credentialsFilePath: '/test/credentials.json', + ); + + expect(authOAuth1ModelDefaults.consumerKey, 'test-consumer-key'); + expect(authOAuth1ModelDefaults.consumerSecret, 'test-consumer-secret'); + expect( + authOAuth1ModelDefaults.credentialsFilePath, + '/test/credentials.json', + ); + expect( + authOAuth1ModelDefaults.signatureMethod, + OAuth1SignatureMethod.hmacSha1, + ); + expect(authOAuth1ModelDefaults.parameterLocation, 'header'); + expect(authOAuth1ModelDefaults.version, '1.0'); + expect(authOAuth1ModelDefaults.includeBodyHash, false); + expect(authOAuth1ModelDefaults.accessToken, isNull); + expect(authOAuth1ModelDefaults.tokenSecret, isNull); + expect(authOAuth1ModelDefaults.realm, isNull); + expect(authOAuth1ModelDefaults.callbackUrl, isNull); + expect(authOAuth1ModelDefaults.verifier, isNull); + expect(authOAuth1ModelDefaults.nonce, isNull); + expect(authOAuth1ModelDefaults.timestamp, isNull); + }); + + test("Testing AuthOAuth1Model with all signature methods", () { + const authOAuth1ModelHmacSha1 = AuthOAuth1Model( + consumerKey: 'test-key', + consumerSecret: 'test-secret', + credentialsFilePath: '/test/credentials.json', + signatureMethod: OAuth1SignatureMethod.hmacSha1, + ); + + const authOAuth1ModelPlaintext = AuthOAuth1Model( + consumerKey: 'test-key', + consumerSecret: 'test-secret', + credentialsFilePath: '/test/credentials.json', + signatureMethod: OAuth1SignatureMethod.plaintext, + ); + + expect( + authOAuth1ModelHmacSha1.signatureMethod, + OAuth1SignatureMethod.hmacSha1, + ); + expect( + authOAuth1ModelPlaintext.signatureMethod, + OAuth1SignatureMethod.plaintext, + ); + expect(authOAuth1ModelHmacSha1.signatureMethod.displayType, 'HMAC-SHA1'); + expect(authOAuth1ModelPlaintext.signatureMethod.displayType, 'Plaintext'); + }); + + test("Testing AuthOAuth1Model parameter locations", () { + const authOAuth1ModelHeader = AuthOAuth1Model( + consumerKey: 'test-key', + consumerSecret: 'test-secret', + credentialsFilePath: '/test/credentials.json', + parameterLocation: 'header', + ); + + const authOAuth1ModelQuery = AuthOAuth1Model( + consumerKey: 'test-key', + consumerSecret: 'test-secret', + credentialsFilePath: '/test/credentials.json', + parameterLocation: 'query', + ); + + expect(authOAuth1ModelHeader.parameterLocation, 'header'); + expect(authOAuth1ModelQuery.parameterLocation, 'query'); + }); + + test("Testing AuthOAuth1Model with optional tokens", () { + const authOAuth1ModelWithTokens = AuthOAuth1Model( + consumerKey: 'test-key', + consumerSecret: 'test-secret', + credentialsFilePath: '/test/credentials.json', + accessToken: 'test-access-token', + tokenSecret: 'test-token-secret', + ); + + const authOAuth1ModelWithoutTokens = AuthOAuth1Model( + consumerKey: 'test-key', + consumerSecret: 'test-secret', + credentialsFilePath: '/test/credentials.json', + ); + + expect(authOAuth1ModelWithTokens.accessToken, 'test-access-token'); + expect(authOAuth1ModelWithTokens.tokenSecret, 'test-token-secret'); + expect(authOAuth1ModelWithoutTokens.accessToken, isNull); + expect(authOAuth1ModelWithoutTokens.tokenSecret, isNull); + }); + + test("Testing AuthOAuth1Model with body hash options", () { + const authOAuth1ModelWithBodyHash = AuthOAuth1Model( + consumerKey: 'test-key', + consumerSecret: 'test-secret', + credentialsFilePath: '/test/credentials.json', + includeBodyHash: true, + ); + + const authOAuth1ModelWithoutBodyHash = AuthOAuth1Model( + consumerKey: 'test-key', + consumerSecret: 'test-secret', + credentialsFilePath: '/test/credentials.json', + includeBodyHash: false, + ); + + expect(authOAuth1ModelWithBodyHash.includeBodyHash, true); + expect(authOAuth1ModelWithoutBodyHash.includeBodyHash, false); + }); + }); +} diff --git a/packages/better_networking/test/models/auth/auth_oauth2_model_test.dart b/packages/better_networking/test/models/auth/auth_oauth2_model_test.dart new file mode 100644 index 000000000..43dbb50ac --- /dev/null +++ b/packages/better_networking/test/models/auth/auth_oauth2_model_test.dart @@ -0,0 +1,309 @@ +import 'package:better_networking/models/auth/auth_oauth2_model.dart'; +import 'package:better_networking/consts.dart'; +import 'package:test/test.dart'; +import 'auth_models.dart'; + +void main() { + group('Testing AuthOAuth2Model', () { + test("Testing AuthOAuth2Model copyWith", () { + var authOAuth2Model = authOAuth2Model1; + final authOAuth2ModelCopyWith = authOAuth2Model.copyWith( + grantType: OAuth2GrantType.clientCredentials, + clientId: 'new_client_id', + scope: 'new_scope', + codeChallengeMethod: 'plain', + ); + expect( + authOAuth2ModelCopyWith.grantType, + OAuth2GrantType.clientCredentials, + ); + expect(authOAuth2ModelCopyWith.clientId, 'new_client_id'); + expect(authOAuth2ModelCopyWith.scope, 'new_scope'); + expect(authOAuth2ModelCopyWith.codeChallengeMethod, 'plain'); + // original model unchanged + expect(authOAuth2Model.grantType, OAuth2GrantType.authorizationCode); + expect(authOAuth2Model.clientId, 'oauth2-client-id-123'); + expect(authOAuth2Model.scope, 'read write admin'); + expect(authOAuth2Model.codeChallengeMethod, 'S256'); + }); + + test("Testing AuthOAuth2Model toJson", () { + var authOAuth2Model = authOAuth2Model1; + expect(authOAuth2Model.toJson(), authOAuth2ModelJson1); + }); + + test("Testing AuthOAuth2Model fromJson", () { + var authOAuth2Model = authOAuth2Model1; + final modelFromJson = AuthOAuth2Model.fromJson(authOAuth2ModelJson1); + expect(modelFromJson, authOAuth2Model); + expect(modelFromJson.grantType, OAuth2GrantType.authorizationCode); + expect( + modelFromJson.authorizationUrl, + 'https://oauth.example.com/authorize', + ); + expect(modelFromJson.accessTokenUrl, 'https://oauth.example.com/token'); + expect(modelFromJson.clientId, 'oauth2-client-id-123'); + expect(modelFromJson.clientSecret, 'oauth2-client-secret-456'); + expect( + modelFromJson.credentialsFilePath, + '/path/to/oauth2/credentials.json', + ); + }); + + test("Testing AuthOAuth2Model getters", () { + var authOAuth2Model = authOAuth2Model1; + expect(authOAuth2Model.grantType, OAuth2GrantType.authorizationCode); + expect( + authOAuth2Model.authorizationUrl, + 'https://oauth.example.com/authorize', + ); + expect(authOAuth2Model.accessTokenUrl, 'https://oauth.example.com/token'); + expect(authOAuth2Model.clientId, 'oauth2-client-id-123'); + expect(authOAuth2Model.clientSecret, 'oauth2-client-secret-456'); + expect( + authOAuth2Model.credentialsFilePath, + '/path/to/oauth2/credentials.json', + ); + expect(authOAuth2Model.redirectUrl, 'https://example.com/redirect'); + expect(authOAuth2Model.scope, 'read write admin'); + expect(authOAuth2Model.state, 'oauth2-state-789'); + expect(authOAuth2Model.codeChallengeMethod, 'S256'); + expect(authOAuth2Model.codeVerifier, 'oauth2-code-verifier-012'); + expect(authOAuth2Model.codeChallenge, 'oauth2-code-challenge-345'); + expect(authOAuth2Model.username, 'oauth2-username'); + expect(authOAuth2Model.password, 'oauth2-password'); + expect(authOAuth2Model.refreshToken, 'oauth2-refresh-token-678'); + expect(authOAuth2Model.identityToken, 'oauth2-identity-token-901'); + expect(authOAuth2Model.accessToken, 'oauth2-access-token-234'); + }); + + test("Testing AuthOAuth2Model equality", () { + const authOAuth2Model1Copy = AuthOAuth2Model( + grantType: OAuth2GrantType.authorizationCode, + authorizationUrl: 'https://oauth.example.com/authorize', + accessTokenUrl: 'https://oauth.example.com/token', + clientId: 'oauth2-client-id-123', + clientSecret: 'oauth2-client-secret-456', + credentialsFilePath: '/path/to/oauth2/credentials.json', + redirectUrl: 'https://example.com/redirect', + scope: 'read write admin', + state: 'oauth2-state-789', + codeChallengeMethod: 'S256', + codeVerifier: 'oauth2-code-verifier-012', + codeChallenge: 'oauth2-code-challenge-345', + username: 'oauth2-username', + password: 'oauth2-password', + refreshToken: 'oauth2-refresh-token-678', + identityToken: 'oauth2-identity-token-901', + accessToken: 'oauth2-access-token-234', + ); + expect(authOAuth2Model1, authOAuth2Model1Copy); + expect(authOAuth2Model1, isNot(authOAuth2Model2)); + }); + + test("Testing AuthOAuth2Model with different values", () { + expect(authOAuth2Model2.grantType, OAuth2GrantType.clientCredentials); + expect( + authOAuth2Model2.authorizationUrl, + 'https://different-oauth.example.com/auth', + ); + expect( + authOAuth2Model2.accessTokenUrl, + 'https://different-oauth.example.com/token', + ); + expect(authOAuth2Model2.clientId, 'different-client-id'); + expect(authOAuth2Model2.clientSecret, 'different-client-secret'); + expect( + authOAuth2Model2.credentialsFilePath, + '/different/oauth2/path.json', + ); + expect(authOAuth2Model2.scope, 'api:read'); + expect(authOAuth2Model2.codeChallengeMethod, 'plain'); + expect(authOAuth2Model1.grantType, isNot(authOAuth2Model2.grantType)); + expect(authOAuth2Model1.clientId, isNot(authOAuth2Model2.clientId)); + }); + + test("Testing AuthOAuth2Model with default values", () { + const authOAuth2ModelDefaults = AuthOAuth2Model( + authorizationUrl: 'https://auth.example.com/authorize', + accessTokenUrl: 'https://auth.example.com/token', + clientId: 'test-client-id', + clientSecret: 'test-client-secret', + credentialsFilePath: '/test/credentials.json', + ); + + expect( + authOAuth2ModelDefaults.grantType, + OAuth2GrantType.authorizationCode, + ); + expect( + authOAuth2ModelDefaults.authorizationUrl, + 'https://auth.example.com/authorize', + ); + expect( + authOAuth2ModelDefaults.accessTokenUrl, + 'https://auth.example.com/token', + ); + expect(authOAuth2ModelDefaults.clientId, 'test-client-id'); + expect(authOAuth2ModelDefaults.clientSecret, 'test-client-secret'); + expect( + authOAuth2ModelDefaults.credentialsFilePath, + '/test/credentials.json', + ); + expect(authOAuth2ModelDefaults.codeChallengeMethod, 'sha-256'); + expect(authOAuth2ModelDefaults.redirectUrl, isNull); + expect(authOAuth2ModelDefaults.scope, isNull); + expect(authOAuth2ModelDefaults.state, isNull); + expect(authOAuth2ModelDefaults.codeVerifier, isNull); + expect(authOAuth2ModelDefaults.codeChallenge, isNull); + expect(authOAuth2ModelDefaults.username, isNull); + expect(authOAuth2ModelDefaults.password, isNull); + expect(authOAuth2ModelDefaults.refreshToken, isNull); + expect(authOAuth2ModelDefaults.identityToken, isNull); + expect(authOAuth2ModelDefaults.accessToken, isNull); + }); + + test("Testing AuthOAuth2Model with all grant types", () { + const authOAuth2ModelAuthCode = AuthOAuth2Model( + grantType: OAuth2GrantType.authorizationCode, + authorizationUrl: 'https://auth.example.com/authorize', + accessTokenUrl: 'https://auth.example.com/token', + clientId: 'test-client-id', + clientSecret: 'test-client-secret', + credentialsFilePath: '/test/credentials.json', + ); + + const authOAuth2ModelClientCreds = AuthOAuth2Model( + grantType: OAuth2GrantType.clientCredentials, + authorizationUrl: 'https://auth.example.com/authorize', + accessTokenUrl: 'https://auth.example.com/token', + clientId: 'test-client-id', + clientSecret: 'test-client-secret', + credentialsFilePath: '/test/credentials.json', + ); + + expect( + authOAuth2ModelAuthCode.grantType, + OAuth2GrantType.authorizationCode, + ); + expect( + authOAuth2ModelClientCreds.grantType, + OAuth2GrantType.clientCredentials, + ); + expect( + authOAuth2ModelAuthCode.grantType.displayType, + 'Authorization Code', + ); + expect( + authOAuth2ModelClientCreds.grantType.displayType, + 'Client Credentials', + ); + }); + + test("Testing AuthOAuth2Model with PKCE parameters", () { + const authOAuth2ModelWithPKCE = AuthOAuth2Model( + authorizationUrl: 'https://auth.example.com/authorize', + accessTokenUrl: 'https://auth.example.com/token', + clientId: 'test-client-id', + clientSecret: 'test-client-secret', + credentialsFilePath: '/test/credentials.json', + codeChallengeMethod: 'S256', + codeVerifier: 'test-code-verifier', + codeChallenge: 'test-code-challenge', + ); + + expect(authOAuth2ModelWithPKCE.codeChallengeMethod, 'S256'); + expect(authOAuth2ModelWithPKCE.codeVerifier, 'test-code-verifier'); + expect(authOAuth2ModelWithPKCE.codeChallenge, 'test-code-challenge'); + }); + + test( + "Testing AuthOAuth2Model with resource owner password credentials", + () { + const authOAuth2ModelWithPassword = AuthOAuth2Model( + authorizationUrl: 'https://auth.example.com/authorize', + accessTokenUrl: 'https://auth.example.com/token', + clientId: 'test-client-id', + clientSecret: 'test-client-secret', + credentialsFilePath: '/test/credentials.json', + username: 'test-username', + password: 'test-password', + ); + + expect(authOAuth2ModelWithPassword.username, 'test-username'); + expect(authOAuth2ModelWithPassword.password, 'test-password'); + }, + ); + + test("Testing AuthOAuth2Model with tokens", () { + const authOAuth2ModelWithTokens = AuthOAuth2Model( + authorizationUrl: 'https://auth.example.com/authorize', + accessTokenUrl: 'https://auth.example.com/token', + clientId: 'test-client-id', + clientSecret: 'test-client-secret', + credentialsFilePath: '/test/credentials.json', + accessToken: 'test-access-token', + refreshToken: 'test-refresh-token', + identityToken: 'test-identity-token', + ); + + expect(authOAuth2ModelWithTokens.accessToken, 'test-access-token'); + expect(authOAuth2ModelWithTokens.refreshToken, 'test-refresh-token'); + expect(authOAuth2ModelWithTokens.identityToken, 'test-identity-token'); + }); + + test("Testing AuthOAuth2Model with scopes and state", () { + const authOAuth2ModelWithScope = AuthOAuth2Model( + authorizationUrl: 'https://auth.example.com/authorize', + accessTokenUrl: 'https://auth.example.com/token', + clientId: 'test-client-id', + clientSecret: 'test-client-secret', + credentialsFilePath: '/test/credentials.json', + scope: 'read write delete', + state: 'test-state-parameter', + ); + + expect(authOAuth2ModelWithScope.scope, 'read write delete'); + expect(authOAuth2ModelWithScope.state, 'test-state-parameter'); + }); + + test("Testing AuthOAuth2Model with redirect URL", () { + const authOAuth2ModelWithRedirect = AuthOAuth2Model( + authorizationUrl: 'https://auth.example.com/authorize', + accessTokenUrl: 'https://auth.example.com/token', + clientId: 'test-client-id', + clientSecret: 'test-client-secret', + credentialsFilePath: '/test/credentials.json', + redirectUrl: 'https://myapp.example.com/oauth/callback', + ); + + expect( + authOAuth2ModelWithRedirect.redirectUrl, + 'https://myapp.example.com/oauth/callback', + ); + }); + + test("Testing AuthOAuth2Model code challenge methods", () { + const authOAuth2ModelSha256 = AuthOAuth2Model( + authorizationUrl: 'https://auth.example.com/authorize', + accessTokenUrl: 'https://auth.example.com/token', + clientId: 'test-client-id', + clientSecret: 'test-client-secret', + credentialsFilePath: '/test/credentials.json', + codeChallengeMethod: 'S256', + ); + + const authOAuth2ModelPlain = AuthOAuth2Model( + authorizationUrl: 'https://auth.example.com/authorize', + accessTokenUrl: 'https://auth.example.com/token', + clientId: 'test-client-id', + clientSecret: 'test-client-secret', + credentialsFilePath: '/test/credentials.json', + codeChallengeMethod: 'plain', + ); + + expect(authOAuth2ModelSha256.codeChallengeMethod, 'S256'); + expect(authOAuth2ModelPlain.codeChallengeMethod, 'plain'); + }); + }); +} diff --git a/packages/better_networking/test/services/oauth_callback_server_test.dart b/packages/better_networking/test/services/oauth_callback_server_test.dart new file mode 100644 index 000000000..fc94e46ed --- /dev/null +++ b/packages/better_networking/test/services/oauth_callback_server_test.dart @@ -0,0 +1,117 @@ +import 'dart:io'; +import 'package:test/test.dart'; +import 'package:better_networking/services/oauth_callback_server.dart'; + +void main() { + group('OAuthCallbackServer', () { + late OAuthCallbackServer server; + + setUp(() { + server = OAuthCallbackServer(); + }); + + tearDown(() async { + await server.stop(); + }); + + test('should start server and return callback URL', () async { + final callbackUrl = await server.start(); + + expect(callbackUrl, startsWith('http://localhost:')); + expect(callbackUrl, endsWith('/callback')); + + // Verify the server is actually running by making a simple HTTP request + final client = HttpClient(); + final uri = Uri.parse(callbackUrl); + final request = await client.getUrl(uri); + final response = await request.close(); + + expect(response.statusCode, equals(HttpStatus.ok)); + client.close(); + }); + + test('should start server with custom path', () async { + final callbackUrl = await server.start(path: '/custom/oauth'); + + expect(callbackUrl, startsWith('http://localhost:')); + expect(callbackUrl, endsWith('/custom/oauth')); + }); + + test('should handle callback and return full URL', () async { + final callbackUrl = await server.start(); + + // Start waiting for callback in a separate isolate + final callbackFuture = server.waitForCallback(); + + // Simulate an OAuth callback with query parameters + final client = HttpClient(); + final uri = Uri.parse('$callbackUrl?code=test_code&state=test_state'); + final request = await client.getUrl(uri); + final response = await request.close(); + + expect(response.statusCode, equals(HttpStatus.ok)); + + // Verify we get the callback URL with parameters + final receivedCallback = await callbackFuture; + expect(receivedCallback, contains('code=test_code')); + expect(receivedCallback, contains('state=test_state')); + + client.close(); + }); + + test('should find available port when default is busy', () async { + // Find a port that's actually available for testing + late int testPort; + late HttpServer busyServer; + + // Try to find an available port in the testing range 9080-9090 + for (int port = 9080; port <= 9090; port++) { + try { + busyServer = await HttpServer.bind( + InternetAddress.loopbackIPv4, + port, + ); + testPort = port; + break; + } catch (e) { + // Port is busy, try next one + if (port == 9090) { + // Skip this test if no ports available in our test range + return; + } + } + } + + try { + final callbackUrl = await server.start(); + + // Should find a different port + expect(callbackUrl, startsWith('http://localhost:')); + expect(callbackUrl, isNot(contains(':$testPort'))); + } finally { + await busyServer.close(); + } + }); + + test('should throw exception when no ports available', () async { + // This test would be difficult to implement without actually occupying all ports, + // so we'll skip it for now. In a real scenario, you could mock the HttpServer.bind method. + }); + + test('should stop server cleanly', () async { + final callbackUrl = await server.start(); + await server.stop(); + + // Verify server is stopped by trying to connect + final client = HttpClient(); + final uri = Uri.parse(callbackUrl); + + expect(() async { + final request = await client.getUrl(uri); + await request.close(); + }, throwsA(isA())); + + client.close(); + }); + }); +} diff --git a/packages/better_networking/test/utils/auth/auth_handling_test.dart b/packages/better_networking/test/utils/auth/auth_handling_test.dart index 40f2cb2a6..9b8857f13 100644 --- a/packages/better_networking/test/utils/auth/auth_handling_test.dart +++ b/packages/better_networking/test/utils/auth/auth_handling_test.dart @@ -423,7 +423,7 @@ void main() { ); test( - 'given handleAuth when OAuth1 authentication is provided then it should throw UnimplementedError', + 'given handleAuth when OAuth1 authentication is provided but oauth1 data is null then it should not add headers', () async { const httpRequestModel = HttpRequestModel( method: HTTPVerb.get, @@ -432,15 +432,15 @@ void main() { const authModel = AuthModel(type: APIAuthType.oauth1); - expect( - () async => await handleAuth(httpRequestModel, authModel), - throwsA(isA()), - ); + final result = await handleAuth(httpRequestModel, authModel); + + expect(result.headers, isEmpty); + expect(result.isHeaderEnabledList, isEmpty); }, ); test( - 'given handleAuth when OAuth2 authentication is provided then it should throw UnimplementedError', + 'given handleAuth when OAuth2 authentication is provided but oauth2 data is null then it should throw Exception', () async { const httpRequestModel = HttpRequestModel( method: HTTPVerb.get, @@ -451,7 +451,13 @@ void main() { expect( () async => await handleAuth(httpRequestModel, authModel), - throwsA(isA()), + throwsA( + isA().having( + (e) => e.toString(), + 'message', + contains('Failed to get OAuth2 Data'), + ), + ), ); }, ); diff --git a/packages/better_networking/test/utils/auth/auth_models_test.dart b/packages/better_networking/test/utils/auth/auth_models_test.dart index 1c7b0cd23..725bf7588 100644 --- a/packages/better_networking/test/utils/auth/auth_models_test.dart +++ b/packages/better_networking/test/utils/auth/auth_models_test.dart @@ -12,6 +12,8 @@ void main() { expect(authModel.apikey, isNull); expect(authModel.jwt, isNull); expect(authModel.digest, isNull); + expect(authModel.oauth1, isNull); + expect(authModel.oauth2, isNull); }); test('should create AuthModel with basic authentication', () { @@ -20,10 +22,7 @@ void main() { password: 'testpass', ); - const authModel = AuthModel( - type: APIAuthType.basic, - basic: basicAuth, - ); + const authModel = AuthModel(type: APIAuthType.basic, basic: basicAuth); expect(authModel.type, APIAuthType.basic); expect(authModel.basic, isNotNull); @@ -34,10 +33,7 @@ void main() { test('should create AuthModel with bearer token', () { const bearerAuth = AuthBearerModel(token: 'bearer-token-123'); - const authModel = AuthModel( - type: APIAuthType.bearer, - bearer: bearerAuth, - ); + const authModel = AuthModel(type: APIAuthType.bearer, bearer: bearerAuth); expect(authModel.type, APIAuthType.bearer); expect(authModel.bearer, isNotNull); @@ -51,10 +47,7 @@ void main() { name: 'X-API-Key', ); - const authModel = AuthModel( - type: APIAuthType.apiKey, - apikey: apiKeyAuth, - ); + const authModel = AuthModel(type: APIAuthType.apiKey, apikey: apiKeyAuth); expect(authModel.type, APIAuthType.apiKey); expect(authModel.apikey, isNotNull); @@ -75,10 +68,7 @@ void main() { header: 'Authorization', ); - const authModel = AuthModel( - type: APIAuthType.jwt, - jwt: jwtAuth, - ); + const authModel = AuthModel(type: APIAuthType.jwt, jwt: jwtAuth); expect(authModel.type, APIAuthType.jwt); expect(authModel.jwt, isNotNull); @@ -98,10 +88,7 @@ void main() { opaque: 'test-opaque', ); - const authModel = AuthModel( - type: APIAuthType.digest, - digest: digestAuth, - ); + const authModel = AuthModel(type: APIAuthType.digest, digest: digestAuth); expect(authModel.type, APIAuthType.digest); expect(authModel.digest, isNotNull); @@ -110,13 +97,306 @@ void main() { expect(authModel.digest?.algorithm, 'MD5'); }); + test('should create AuthModel with OAuth1 authentication', () { + const oauth1Auth = AuthOAuth1Model( + consumerKey: 'oauth1-consumer-key', + consumerSecret: 'oauth1-consumer-secret', + credentialsFilePath: '/path/to/credentials.json', + accessToken: 'oauth1-access-token', + tokenSecret: 'oauth1-token-secret', + signatureMethod: OAuth1SignatureMethod.hmacSha1, + parameterLocation: 'header', + version: '1.0', + realm: 'oauth1-realm', + callbackUrl: 'https://example.com/callback', + includeBodyHash: false, + ); + + const authModel = AuthModel(type: APIAuthType.oauth1, oauth1: oauth1Auth); + + expect(authModel.type, APIAuthType.oauth1); + expect(authModel.oauth1, isNotNull); + expect(authModel.oauth1?.consumerKey, 'oauth1-consumer-key'); + expect(authModel.oauth1?.consumerSecret, 'oauth1-consumer-secret'); + expect( + authModel.oauth1?.credentialsFilePath, + '/path/to/credentials.json', + ); + expect(authModel.oauth1?.accessToken, 'oauth1-access-token'); + expect(authModel.oauth1?.tokenSecret, 'oauth1-token-secret'); + expect(authModel.oauth1?.signatureMethod, OAuth1SignatureMethod.hmacSha1); + expect(authModel.oauth1?.parameterLocation, 'header'); + expect(authModel.oauth1?.version, '1.0'); + expect(authModel.oauth1?.realm, 'oauth1-realm'); + expect(authModel.oauth1?.callbackUrl, 'https://example.com/callback'); + expect(authModel.oauth1?.includeBodyHash, false); + }); + + test('should create AuthModel with OAuth2 authentication', () { + const oauth2Auth = AuthOAuth2Model( + grantType: OAuth2GrantType.authorizationCode, + authorizationUrl: 'https://oauth.example.com/authorize', + accessTokenUrl: 'https://oauth.example.com/token', + clientId: 'oauth2-client-id', + clientSecret: 'oauth2-client-secret', + credentialsFilePath: '/path/to/oauth2/credentials.json', + redirectUrl: 'https://example.com/redirect', + scope: 'read write', + state: 'oauth2-state', + codeChallengeMethod: 'S256', + username: 'oauth2-username', + password: 'oauth2-password', + refreshToken: 'oauth2-refresh-token', + accessToken: 'oauth2-access-token', + ); + + const authModel = AuthModel(type: APIAuthType.oauth2, oauth2: oauth2Auth); + + expect(authModel.type, APIAuthType.oauth2); + expect(authModel.oauth2, isNotNull); + expect(authModel.oauth2?.grantType, OAuth2GrantType.authorizationCode); + expect( + authModel.oauth2?.authorizationUrl, + 'https://oauth.example.com/authorize', + ); + expect( + authModel.oauth2?.accessTokenUrl, + 'https://oauth.example.com/token', + ); + expect(authModel.oauth2?.clientId, 'oauth2-client-id'); + expect(authModel.oauth2?.clientSecret, 'oauth2-client-secret'); + expect( + authModel.oauth2?.credentialsFilePath, + '/path/to/oauth2/credentials.json', + ); + expect(authModel.oauth2?.redirectUrl, 'https://example.com/redirect'); + expect(authModel.oauth2?.scope, 'read write'); + expect(authModel.oauth2?.state, 'oauth2-state'); + expect(authModel.oauth2?.codeChallengeMethod, 'S256'); + expect(authModel.oauth2?.username, 'oauth2-username'); + expect(authModel.oauth2?.password, 'oauth2-password'); + expect(authModel.oauth2?.refreshToken, 'oauth2-refresh-token'); + expect(authModel.oauth2?.accessToken, 'oauth2-access-token'); + }); + + test('should handle OAuth1 with default values', () { + const oauth1Auth = AuthOAuth1Model( + consumerKey: 'test-consumer-key', + consumerSecret: 'test-consumer-secret', + credentialsFilePath: '/default/path/credentials.json', + ); + + expect(oauth1Auth.consumerKey, 'test-consumer-key'); + expect(oauth1Auth.consumerSecret, 'test-consumer-secret'); + expect(oauth1Auth.credentialsFilePath, '/default/path/credentials.json'); + expect(oauth1Auth.signatureMethod, OAuth1SignatureMethod.hmacSha1); + expect(oauth1Auth.parameterLocation, 'header'); + expect(oauth1Auth.version, '1.0'); + expect(oauth1Auth.includeBodyHash, false); + expect(oauth1Auth.accessToken, isNull); + expect(oauth1Auth.tokenSecret, isNull); + }); + + test('should handle OAuth1 with custom values', () { + const oauth1Auth = AuthOAuth1Model( + consumerKey: 'custom-consumer-key', + consumerSecret: 'custom-consumer-secret', + credentialsFilePath: '/custom/path/credentials.json', + accessToken: 'custom-access-token', + tokenSecret: 'custom-token-secret', + signatureMethod: OAuth1SignatureMethod.plaintext, + parameterLocation: 'query', + version: '1.0a', + realm: 'custom-realm', + callbackUrl: 'https://custom.example.com/callback', + verifier: 'custom-verifier', + nonce: 'custom-nonce', + timestamp: '1640995200', + includeBodyHash: true, + ); + + expect(oauth1Auth.consumerKey, 'custom-consumer-key'); + expect(oauth1Auth.consumerSecret, 'custom-consumer-secret'); + expect(oauth1Auth.signatureMethod, OAuth1SignatureMethod.plaintext); + expect(oauth1Auth.parameterLocation, 'query'); + expect(oauth1Auth.version, '1.0a'); + expect(oauth1Auth.realm, 'custom-realm'); + expect(oauth1Auth.callbackUrl, 'https://custom.example.com/callback'); + expect(oauth1Auth.verifier, 'custom-verifier'); + expect(oauth1Auth.nonce, 'custom-nonce'); + expect(oauth1Auth.timestamp, '1640995200'); + expect(oauth1Auth.includeBodyHash, true); + }); + + test('should handle OAuth2 with default values', () { + const oauth2Auth = AuthOAuth2Model( + authorizationUrl: 'https://auth.example.com/authorize', + accessTokenUrl: 'https://auth.example.com/token', + clientId: 'test-client-id', + clientSecret: 'test-client-secret', + credentialsFilePath: '/default/oauth2/credentials.json', + ); + + expect(oauth2Auth.grantType, OAuth2GrantType.authorizationCode); + expect(oauth2Auth.authorizationUrl, 'https://auth.example.com/authorize'); + expect(oauth2Auth.accessTokenUrl, 'https://auth.example.com/token'); + expect(oauth2Auth.clientId, 'test-client-id'); + expect(oauth2Auth.clientSecret, 'test-client-secret'); + expect( + oauth2Auth.credentialsFilePath, + '/default/oauth2/credentials.json', + ); + expect(oauth2Auth.codeChallengeMethod, 'sha-256'); + expect(oauth2Auth.redirectUrl, isNull); + expect(oauth2Auth.scope, isNull); + expect(oauth2Auth.state, isNull); + }); + + test('should handle OAuth2 with custom grant type and values', () { + const oauth2Auth = AuthOAuth2Model( + grantType: OAuth2GrantType.clientCredentials, + authorizationUrl: 'https://custom-auth.example.com/authorize', + accessTokenUrl: 'https://custom-auth.example.com/token', + clientId: 'custom-client-id', + clientSecret: 'custom-client-secret', + credentialsFilePath: '/custom/oauth2/credentials.json', + redirectUrl: 'https://custom.example.com/redirect', + scope: 'read write admin', + state: 'custom-state', + codeChallengeMethod: 'plain', + codeVerifier: 'custom-code-verifier', + codeChallenge: 'custom-code-challenge', + username: 'custom-username', + password: 'custom-password', + refreshToken: 'custom-refresh-token', + identityToken: 'custom-identity-token', + accessToken: 'custom-access-token', + ); + + expect(oauth2Auth.grantType, OAuth2GrantType.clientCredentials); + expect( + oauth2Auth.authorizationUrl, + 'https://custom-auth.example.com/authorize', + ); + expect( + oauth2Auth.accessTokenUrl, + 'https://custom-auth.example.com/token', + ); + expect(oauth2Auth.clientId, 'custom-client-id'); + expect(oauth2Auth.clientSecret, 'custom-client-secret'); + expect(oauth2Auth.redirectUrl, 'https://custom.example.com/redirect'); + expect(oauth2Auth.scope, 'read write admin'); + expect(oauth2Auth.state, 'custom-state'); + expect(oauth2Auth.codeChallengeMethod, 'plain'); + expect(oauth2Auth.codeVerifier, 'custom-code-verifier'); + expect(oauth2Auth.codeChallenge, 'custom-code-challenge'); + expect(oauth2Auth.username, 'custom-username'); + expect(oauth2Auth.password, 'custom-password'); + expect(oauth2Auth.refreshToken, 'custom-refresh-token'); + expect(oauth2Auth.identityToken, 'custom-identity-token'); + expect(oauth2Auth.accessToken, 'custom-access-token'); + }); + + test( + 'should serialize and deserialize AuthModel with OAuth1 correctly', + () { + const originalModel = AuthModel( + type: APIAuthType.oauth1, + oauth1: AuthOAuth1Model( + consumerKey: 'test-consumer-key', + consumerSecret: 'test-consumer-secret', + credentialsFilePath: '/test/credentials.json', + accessToken: 'test-access-token', + tokenSecret: 'test-token-secret', + ), + ); + + final json = originalModel.toJson(); + final deserializedModel = AuthModel.fromJson(json); + + expect(deserializedModel.type, originalModel.type); + expect( + deserializedModel.oauth1?.consumerKey, + originalModel.oauth1?.consumerKey, + ); + expect( + deserializedModel.oauth1?.consumerSecret, + originalModel.oauth1?.consumerSecret, + ); + expect( + deserializedModel.oauth1?.credentialsFilePath, + originalModel.oauth1?.credentialsFilePath, + ); + expect( + deserializedModel.oauth1?.accessToken, + originalModel.oauth1?.accessToken, + ); + expect( + deserializedModel.oauth1?.tokenSecret, + originalModel.oauth1?.tokenSecret, + ); + }, + ); + + test( + 'should serialize and deserialize AuthModel with OAuth2 correctly', + () { + const originalModel = AuthModel( + type: APIAuthType.oauth2, + oauth2: AuthOAuth2Model( + grantType: OAuth2GrantType.authorizationCode, + authorizationUrl: 'https://auth.example.com/authorize', + accessTokenUrl: 'https://auth.example.com/token', + clientId: 'test-client-id', + clientSecret: 'test-client-secret', + credentialsFilePath: '/test/oauth2/credentials.json', + redirectUrl: 'https://example.com/redirect', + scope: 'read write', + state: 'test-state', + ), + ); + + final json = originalModel.toJson(); + final deserializedModel = AuthModel.fromJson(json); + + expect(deserializedModel.type, originalModel.type); + expect( + deserializedModel.oauth2?.grantType, + originalModel.oauth2?.grantType, + ); + expect( + deserializedModel.oauth2?.authorizationUrl, + originalModel.oauth2?.authorizationUrl, + ); + expect( + deserializedModel.oauth2?.accessTokenUrl, + originalModel.oauth2?.accessTokenUrl, + ); + expect( + deserializedModel.oauth2?.clientId, + originalModel.oauth2?.clientId, + ); + expect( + deserializedModel.oauth2?.clientSecret, + originalModel.oauth2?.clientSecret, + ); + expect( + deserializedModel.oauth2?.credentialsFilePath, + originalModel.oauth2?.credentialsFilePath, + ); + expect( + deserializedModel.oauth2?.redirectUrl, + originalModel.oauth2?.redirectUrl, + ); + expect(deserializedModel.oauth2?.scope, originalModel.oauth2?.scope); + expect(deserializedModel.oauth2?.state, originalModel.oauth2?.state); + }, + ); + test('should serialize and deserialize AuthModel correctly', () { const originalModel = AuthModel( type: APIAuthType.basic, - basic: AuthBasicAuthModel( - username: 'testuser', - password: 'testpass', - ), + basic: AuthBasicAuthModel(username: 'testuser', password: 'testpass'), ); final json = originalModel.toJson(); @@ -130,10 +410,7 @@ void main() { test('should handle copyWith for AuthModel', () { const originalModel = AuthModel( type: APIAuthType.basic, - basic: AuthBasicAuthModel( - username: 'testuser', - password: 'testpass', - ), + basic: AuthBasicAuthModel(username: 'testuser', password: 'testpass'), ); const newBasicAuth = AuthBasicAuthModel( @@ -155,7 +432,7 @@ void main() { const apiKeyAuth = AuthApiKeyModel(key: 'test-key'); expect(apiKeyAuth.key, 'test-key'); - expect(apiKeyAuth.location, 'header'); + expect(apiKeyAuth.location, 'header'); expect(apiKeyAuth.name, 'x-api-key'); }); @@ -191,15 +468,9 @@ void main() { }); test('should handle edge cases with empty strings', () { - const basicAuth = AuthBasicAuthModel( - username: '', - password: '', - ); + const basicAuth = AuthBasicAuthModel(username: '', password: ''); - const authModel = AuthModel( - type: APIAuthType.basic, - basic: basicAuth, - ); + const authModel = AuthModel(type: APIAuthType.basic, basic: basicAuth); expect(authModel.basic?.username, ''); expect(authModel.basic?.password, ''); @@ -217,6 +488,8 @@ void main() { expect(deserializedModel.apikey, isNull); expect(deserializedModel.jwt, isNull); expect(deserializedModel.digest, isNull); + expect(deserializedModel.oauth1, isNull); + expect(deserializedModel.oauth2, isNull); }); test('should handle complex JWT payload', () { @@ -290,12 +563,26 @@ void main() { basic: AuthBasicAuthModel(username: 'user', password: 'pass'), bearer: AuthBearerModel(token: 'token'), apikey: AuthApiKeyModel(key: 'key'), + oauth1: AuthOAuth1Model( + consumerKey: 'consumer-key', + consumerSecret: 'consumer-secret', + credentialsFilePath: '/path/credentials.json', + ), + oauth2: AuthOAuth2Model( + authorizationUrl: 'https://auth.example.com/authorize', + accessTokenUrl: 'https://auth.example.com/token', + clientId: 'client-id', + clientSecret: 'client-secret', + credentialsFilePath: '/path/oauth2-credentials.json', + ), ); expect(authModel.type, APIAuthType.bearer); expect(authModel.basic, isNotNull); expect(authModel.bearer, isNotNull); expect(authModel.apikey, isNotNull); + expect(authModel.oauth1, isNotNull); + expect(authModel.oauth2, isNotNull); }); test('should handle serialization with special characters', () { @@ -337,4 +624,172 @@ void main() { expect(deserializedModel.basic?.username, 'user_测试_тест_テスト'); expect(deserializedModel.basic?.password, 'password_🔑_🚀_💻'); }); + + test('should handle OAuth1 with minimal required fields', () { + const oauth1Auth = AuthOAuth1Model( + consumerKey: 'minimal-key', + consumerSecret: 'minimal-secret', + credentialsFilePath: '/minimal/path.json', + ); + + const authModel = AuthModel(type: APIAuthType.oauth1, oauth1: oauth1Auth); + + expect(authModel.type, APIAuthType.oauth1); + expect(authModel.oauth1?.consumerKey, 'minimal-key'); + expect(authModel.oauth1?.consumerSecret, 'minimal-secret'); + expect(authModel.oauth1?.credentialsFilePath, '/minimal/path.json'); + expect(authModel.oauth1?.accessToken, isNull); + expect(authModel.oauth1?.tokenSecret, isNull); + }); + + test('should handle OAuth2 with minimal required fields', () { + const oauth2Auth = AuthOAuth2Model( + authorizationUrl: 'https://min-auth.example.com/authorize', + accessTokenUrl: 'https://min-auth.example.com/token', + clientId: 'minimal-client-id', + clientSecret: 'minimal-client-secret', + credentialsFilePath: '/minimal/oauth2.json', + ); + + const authModel = AuthModel(type: APIAuthType.oauth2, oauth2: oauth2Auth); + + expect(authModel.type, APIAuthType.oauth2); + expect( + authModel.oauth2?.authorizationUrl, + 'https://min-auth.example.com/authorize', + ); + expect( + authModel.oauth2?.accessTokenUrl, + 'https://min-auth.example.com/token', + ); + expect(authModel.oauth2?.clientId, 'minimal-client-id'); + expect(authModel.oauth2?.clientSecret, 'minimal-client-secret'); + expect(authModel.oauth2?.credentialsFilePath, '/minimal/oauth2.json'); + expect(authModel.oauth2?.redirectUrl, isNull); + expect(authModel.oauth2?.scope, isNull); + }); + + test('should handle OAuth credentials with special characters', () { + const oauth1Auth = AuthOAuth1Model( + consumerKey: 'key_with@special#chars%', + consumerSecret: 'secret_with!@#\$%^&*()', + credentialsFilePath: '/path/with spaces/credentials.json', + accessToken: 'token_with-dashes_and.dots', + tokenSecret: 'secret_with/slashes\\backslashes', + ); + + const authModel = AuthModel(type: APIAuthType.oauth1, oauth1: oauth1Auth); + + final json = authModel.toJson(); + final deserializedModel = AuthModel.fromJson(json); + + expect(deserializedModel.oauth1?.consumerKey, 'key_with@special#chars%'); + expect(deserializedModel.oauth1?.consumerSecret, 'secret_with!@#\$%^&*()'); + expect( + deserializedModel.oauth1?.credentialsFilePath, + '/path/with spaces/credentials.json', + ); + expect(deserializedModel.oauth1?.accessToken, 'token_with-dashes_and.dots'); + expect( + deserializedModel.oauth1?.tokenSecret, + 'secret_with/slashes\\backslashes', + ); + }); + + test('should handle OAuth2 URLs with complex query parameters', () { + const oauth2Auth = AuthOAuth2Model( + authorizationUrl: + 'https://auth.example.com/authorize?response_type=code&client_id=test&redirect_uri=https://app.com/callback', + accessTokenUrl: + 'https://auth.example.com/token?grant_type=authorization_code', + clientId: 'complex-client-id', + clientSecret: 'complex-client-secret', + credentialsFilePath: '/complex/oauth2.json', + redirectUrl: 'https://app.example.com/callback?state=abc123&code=def456', + scope: 'read:user write:repo admin:org', + ); + + const authModel = AuthModel(type: APIAuthType.oauth2, oauth2: oauth2Auth); + + final json = authModel.toJson(); + final deserializedModel = AuthModel.fromJson(json); + + expect( + deserializedModel.oauth2?.authorizationUrl, + 'https://auth.example.com/authorize?response_type=code&client_id=test&redirect_uri=https://app.com/callback', + ); + expect( + deserializedModel.oauth2?.accessTokenUrl, + 'https://auth.example.com/token?grant_type=authorization_code', + ); + expect( + deserializedModel.oauth2?.redirectUrl, + 'https://app.example.com/callback?state=abc123&code=def456', + ); + expect(deserializedModel.oauth2?.scope, 'read:user write:repo admin:org'); + }); + + test('should handle copyWith for OAuth1 AuthModel', () { + const originalModel = AuthModel( + type: APIAuthType.oauth1, + oauth1: AuthOAuth1Model( + consumerKey: 'original-key', + consumerSecret: 'original-secret', + credentialsFilePath: '/original/path.json', + ), + ); + + const newOAuth1Auth = AuthOAuth1Model( + consumerKey: 'new-key', + consumerSecret: 'new-secret', + credentialsFilePath: '/new/path.json', + accessToken: 'new-access-token', + ); + + final copiedModel = originalModel.copyWith(oauth1: newOAuth1Auth); + + expect(copiedModel.type, APIAuthType.oauth1); + expect(copiedModel.oauth1?.consumerKey, 'new-key'); + expect(copiedModel.oauth1?.consumerSecret, 'new-secret'); + expect(copiedModel.oauth1?.credentialsFilePath, '/new/path.json'); + expect(copiedModel.oauth1?.accessToken, 'new-access-token'); + }); + + test('should handle copyWith for OAuth2 AuthModel', () { + const originalModel = AuthModel( + type: APIAuthType.oauth2, + oauth2: AuthOAuth2Model( + authorizationUrl: 'https://original-auth.example.com/authorize', + accessTokenUrl: 'https://original-auth.example.com/token', + clientId: 'original-client-id', + clientSecret: 'original-client-secret', + credentialsFilePath: '/original/oauth2.json', + ), + ); + + const newOAuth2Auth = AuthOAuth2Model( + authorizationUrl: 'https://new-auth.example.com/authorize', + accessTokenUrl: 'https://new-auth.example.com/token', + clientId: 'new-client-id', + clientSecret: 'new-client-secret', + credentialsFilePath: '/new/oauth2.json', + scope: 'new-scope', + ); + + final copiedModel = originalModel.copyWith(oauth2: newOAuth2Auth); + + expect(copiedModel.type, APIAuthType.oauth2); + expect( + copiedModel.oauth2?.authorizationUrl, + 'https://new-auth.example.com/authorize', + ); + expect( + copiedModel.oauth2?.accessTokenUrl, + 'https://new-auth.example.com/token', + ); + expect(copiedModel.oauth2?.clientId, 'new-client-id'); + expect(copiedModel.oauth2?.clientSecret, 'new-client-secret'); + expect(copiedModel.oauth2?.credentialsFilePath, '/new/oauth2.json'); + expect(copiedModel.oauth2?.scope, 'new-scope'); + }); } diff --git a/packages/better_networking/test/utils/platform_utils_test.dart b/packages/better_networking/test/utils/platform_utils_test.dart new file mode 100644 index 000000000..63f840383 --- /dev/null +++ b/packages/better_networking/test/utils/platform_utils_test.dart @@ -0,0 +1,34 @@ +import 'package:test/test.dart'; +import 'package:better_networking/utils/platform_utils.dart'; + +void main() { + group('PlatformUtils', () { + test('should detect platform types correctly', () { + // Note: These tests will behave differently based on the platform running the tests + // In a real CI environment, you might want to mock these or run platform-specific tests + + expect(PlatformUtils.isDesktop, isA()); + expect(PlatformUtils.isMobile, isA()); + expect(PlatformUtils.isWeb, isA()); + expect(PlatformUtils.shouldUseLocalhostCallback, isA()); + }); + + test('should have mutually exclusive platform types', () { + // At most one of these should be true (could be none if running on an unknown platform) + final platformCount = [ + PlatformUtils.isDesktop, + PlatformUtils.isMobile, + PlatformUtils.isWeb, + ].where((x) => x).length; + + expect(platformCount, lessThanOrEqualTo(1)); + }); + + test('shouldUseLocalhostCallback should match isDesktop', () { + expect( + PlatformUtils.shouldUseLocalhostCallback, + equals(PlatformUtils.isDesktop), + ); + }); + }); +} diff --git a/pubspec.lock b/pubspec.lock index 67470fd9b..24514a531 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -414,6 +414,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.1" + desktop_webview_window: + dependency: transitive + description: + name: desktop_webview_window + sha256: "57cf20d81689d5cbb1adfd0017e96b669398a669d927906073b0e42fc64111c0" + url: "https://pub.dev" + source: hosted + version: "0.2.3" ed25519_edwards: dependency: transitive description: @@ -677,6 +685,22 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_web_auth_2: + dependency: transitive + description: + name: flutter_web_auth_2 + sha256: "2483d1fd3c45fe1262446e8d5f5490f01b864f2e7868ffe05b4727e263cc0182" + url: "https://pub.dev" + source: hosted + version: "5.0.0-alpha.3" + flutter_web_auth_2_platform_interface: + dependency: transitive + description: + name: flutter_web_auth_2_platform_interface + sha256: "45927587ebb2364cd273675ec95f6f67b81725754b416cef2b65cdc63fd3e853" + url: "https://pub.dev" + source: hosted + version: "5.0.0-alpha.0" flutter_web_plugins: dependency: transitive description: flutter @@ -1174,6 +1198,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.2" + oauth1: + dependency: transitive + description: + name: oauth1 + sha256: "1d424e3c24017a6c5714acb12e0dd76c2fdff96db6d6ef0aab58c925ffc28ae0" + url: "https://pub.dev" + source: hosted + version: "2.1.0" + oauth2: + dependency: transitive + description: + name: oauth2 + sha256: c84470642cbb2bec450ccab2f8520c079cd1ca546a76ffd5c40589e07f4e8bf4 + url: "https://pub.dev" + source: hosted + version: "2.0.3" ollama_dart: dependency: "direct main" description: @@ -2043,6 +2083,14 @@ packages: url: "https://github.com/google/flutter-desktop-embedding.git" source: git version: "0.1.0" + window_to_front: + dependency: transitive + description: + name: window_to_front + sha256: "7aef379752b7190c10479e12b5fd7c0b9d92adc96817d9e96c59937929512aee" + url: "https://pub.dev" + source: hosted + version: "0.0.3" xdg_directories: dependency: transitive description: diff --git a/test/models/history_models.dart b/test/models/history_models.dart index 4256c7d43..69d2fd067 100644 --- a/test/models/history_models.dart +++ b/test/models/history_models.dart @@ -67,7 +67,9 @@ final Map historyRequestModelJson1 = { 'bearer': null, 'basic': null, 'jwt': null, - 'digest': null + 'digest': null, + 'oauth1': null, + 'oauth2': null } }; diff --git a/test/models/http_request_models.dart b/test/models/http_request_models.dart index f5980fbe6..7df385c3f 100644 --- a/test/models/http_request_models.dart +++ b/test/models/http_request_models.dart @@ -390,7 +390,9 @@ const httpRequestModelGet4Json = { 'bearer': null, 'basic': null, 'jwt': null, - 'digest': null + 'digest': null, + 'oauth1': null, + 'oauth2': null }, "isHeaderEnabledList": null, "isParamEnabledList": null, @@ -417,7 +419,9 @@ const httpRequestModelPost10Json = { 'bearer': null, 'basic': null, 'jwt': null, - 'digest': null + 'digest': null, + 'oauth1': null, + 'oauth2': null }, 'isHeaderEnabledList': [false, true], 'isParamEnabledList': null, diff --git a/test/screens/common_widgets/auth/oauth1_fields_test.dart b/test/screens/common_widgets/auth/oauth1_fields_test.dart new file mode 100644 index 000000000..4a61e318a --- /dev/null +++ b/test/screens/common_widgets/auth/oauth1_fields_test.dart @@ -0,0 +1,641 @@ +import 'package:apidash/screens/common_widgets/auth/oauth1_fields.dart'; +import 'package:apidash/screens/common_widgets/common_widgets.dart'; +import 'package:apidash_core/apidash_core.dart'; +import 'package:apidash_design_system/apidash_design_system.dart'; +import 'package:extended_text_field/extended_text_field.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_portal/flutter_portal.dart'; +import 'package:apidash/providers/settings_providers.dart'; +import 'package:apidash/providers/collection_providers.dart'; +import 'package:apidash/models/models.dart'; + +void main() { + group('OAuth1Fields Widget Tests', () { + late AuthModel? mockAuthData; + late Function(AuthModel?) mockUpdateAuth; + late List capturedAuthUpdates; + + setUp(() { + capturedAuthUpdates = []; + mockUpdateAuth = (AuthModel? authModel) { + capturedAuthUpdates.add(authModel); + }; + }); + + // Helper function to wrap OAuth1Fields widget with proper layout constraints + + testWidgets('renders with default values when authData is null', + (WidgetTester tester) async { + mockAuthData = null; + + await tester.pumpWidget( + ProviderScope( + overrides: [ + settingsProvider.overrideWith((ref) => ThemeStateNotifier( + settingsModel: const SettingsModel( + workspaceFolderPath: '/test/workspace', + ), + )), + selectedRequestModelProvider.overrideWith((ref) => null), + ], + child: Portal( + child: MaterialApp( + home: Scaffold( + body: SingleChildScrollView( + child: OAuth1Fields( + authData: mockAuthData, + updateAuth: mockUpdateAuth, + ), + ), + ), + ), + ), + ), + ); + + expect(find.byType(EnvAuthField), findsNWidgets(9)); + expect(find.byType(ADPopupMenu), findsOneWidget); + expect(find.text('Signature Method'), findsOneWidget); + }); + + testWidgets('renders with existing OAuth1 auth data', + (WidgetTester tester) async { + mockAuthData = const AuthModel( + type: APIAuthType.oauth1, + oauth1: AuthOAuth1Model( + consumerKey: 'test_consumer_key', + consumerSecret: 'test_consumer_secret', + accessToken: 'test_access_token', + tokenSecret: 'test_token_secret', + signatureMethod: OAuth1SignatureMethod.hmacSha1, + parameterLocation: 'url', + callbackUrl: 'http://api.apidash.dev/callback', + verifier: 'test_verifier', + timestamp: '1234567890', + nonce: 'test_nonce', + realm: 'test_realm', + credentialsFilePath: '/test/path', + ), + ); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + settingsProvider.overrideWith((ref) => ThemeStateNotifier( + settingsModel: const SettingsModel( + workspaceFolderPath: '/test/workspace', + ), + )), + selectedRequestModelProvider.overrideWith((ref) => null), + ], + child: Portal( + child: MaterialApp( + home: Scaffold( + body: SingleChildScrollView( + child: OAuth1Fields( + authData: mockAuthData, + updateAuth: mockUpdateAuth, + ), + ), + ), + ), + ), + ), + ); + + expect(find.byType(EnvAuthField), findsNWidgets(9)); + expect(find.byType(ADPopupMenu), findsOneWidget); + }); + + testWidgets('updates auth data when consumer key changes', + (WidgetTester tester) async { + mockAuthData = const AuthModel( + type: APIAuthType.oauth1, + oauth1: AuthOAuth1Model( + consumerKey: 'old_key', + consumerSecret: 'secret', + signatureMethod: OAuth1SignatureMethod.hmacSha1, + parameterLocation: 'url', + credentialsFilePath: '/test/path', + ), + ); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + settingsProvider.overrideWith((ref) => ThemeStateNotifier( + settingsModel: const SettingsModel( + workspaceFolderPath: '/test/workspace', + ), + )), + selectedRequestModelProvider.overrideWith((ref) => null), + ], + child: Portal( + child: MaterialApp( + home: Scaffold( + body: SingleChildScrollView( + child: OAuth1Fields( + authData: mockAuthData, + updateAuth: mockUpdateAuth, + ), + ), + ), + ), + ), + ), + ); + + // Find the consumer key field using find.descendant + expect(find.byType(EnvAuthField), findsNWidgets(9)); + + final consumerKeyField = find.descendant( + of: find.byType(EnvAuthField).first, + matching: find.byType(ExtendedTextField), + ); + await tester.tap(consumerKeyField); + await tester.pumpAndSettle(); + + // Use tester.testTextInput to enter text directly + tester.testTextInput.enterText('new_consumer_key'); + await tester.pumpAndSettle(); + + // Verify that updateAuth was called + expect(capturedAuthUpdates.length, greaterThan(0)); + final lastUpdate = capturedAuthUpdates.last; + expect(lastUpdate?.oauth1?.consumerKey, 'new_consumer_key'); + expect(lastUpdate?.type, APIAuthType.oauth1); + }); + + testWidgets('updates auth data when consumer secret changes', + (WidgetTester tester) async { + mockAuthData = const AuthModel( + type: APIAuthType.oauth1, + oauth1: AuthOAuth1Model( + consumerKey: 'key', + consumerSecret: 'old_secret', + signatureMethod: OAuth1SignatureMethod.hmacSha1, + parameterLocation: 'url', + credentialsFilePath: '/test/path', + ), + ); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + settingsProvider.overrideWith((ref) => ThemeStateNotifier( + settingsModel: const SettingsModel( + workspaceFolderPath: '/test/workspace', + ), + )), + selectedRequestModelProvider.overrideWith((ref) => null), + ], + child: Portal( + child: MaterialApp( + home: Scaffold( + body: SingleChildScrollView( + child: OAuth1Fields( + authData: mockAuthData, + updateAuth: mockUpdateAuth, + ), + ), + ), + ), + ), + ), + ); + + // Find the consumer secret field using find.descendant + expect(find.byType(EnvAuthField), findsNWidgets(9)); + + final consumerSecretField = find.descendant( + of: find.byType(EnvAuthField).at(1), + matching: find.byType(ExtendedTextField), + ); + await tester.tap(consumerSecretField); + await tester.pumpAndSettle(); + + // Use tester.testTextInput to enter text directly + tester.testTextInput.enterText('new_consumer_secret'); + await tester.pumpAndSettle(); + + // Verify that updateAuth was called + expect(capturedAuthUpdates.length, greaterThan(0)); + final lastUpdate = capturedAuthUpdates.last; + expect(lastUpdate?.oauth1?.consumerSecret, 'new_consumer_secret'); + expect(lastUpdate?.type, APIAuthType.oauth1); + }); + + testWidgets('updates auth data when signature method changes', + (WidgetTester tester) async { + mockAuthData = const AuthModel( + type: APIAuthType.oauth1, + oauth1: AuthOAuth1Model( + consumerKey: 'key', + consumerSecret: 'secret', + signatureMethod: OAuth1SignatureMethod.hmacSha1, + parameterLocation: 'url', + credentialsFilePath: '/test/path', + ), + ); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + settingsProvider.overrideWith((ref) => ThemeStateNotifier( + settingsModel: const SettingsModel( + workspaceFolderPath: '/test/workspace', + ), + )), + selectedRequestModelProvider.overrideWith((ref) => null), + ], + child: Portal( + child: MaterialApp( + home: Scaffold( + body: SingleChildScrollView( + child: OAuth1Fields( + authData: mockAuthData, + updateAuth: mockUpdateAuth, + ), + ), + ), + ), + ), + ), + ); + + // Find and tap the signature method dropdown + final signatureMethodDropdown = + find.byType(ADPopupMenu); + await tester.tap(signatureMethodDropdown); + await tester.pumpAndSettle(); + + // Select a different signature method + await tester.tap(find.text('Plaintext').last); + await tester.pumpAndSettle(); + + // Verify that updateAuth was called + expect(capturedAuthUpdates.length, greaterThan(0)); + final lastUpdate = capturedAuthUpdates.last; + expect( + lastUpdate?.oauth1?.signatureMethod, OAuth1SignatureMethod.plaintext); + expect(lastUpdate?.type, APIAuthType.oauth1); + }); + + testWidgets('updates auth data when access token changes', + (WidgetTester tester) async { + mockAuthData = const AuthModel( + type: APIAuthType.oauth1, + oauth1: AuthOAuth1Model( + consumerKey: 'key', + consumerSecret: 'secret', + accessToken: 'old_token', + signatureMethod: OAuth1SignatureMethod.hmacSha1, + parameterLocation: 'url', + credentialsFilePath: '/test/path', + ), + ); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + settingsProvider.overrideWith((ref) => ThemeStateNotifier( + settingsModel: const SettingsModel( + workspaceFolderPath: '/test/workspace', + ), + )), + selectedRequestModelProvider.overrideWith((ref) => null), + ], + child: Portal( + child: MaterialApp( + home: Scaffold( + body: SingleChildScrollView( + child: OAuth1Fields( + authData: mockAuthData, + updateAuth: mockUpdateAuth, + ), + ), + ), + ), + ), + ), + ); + + // Find the access token field using find.descendant + expect(find.byType(EnvAuthField), findsNWidgets(9)); + + final accessTokenField = find.descendant( + of: find.byType(EnvAuthField).at(2), + matching: find.byType(ExtendedTextField), + ); + await tester.tap(accessTokenField); + await tester.pumpAndSettle(); + + // Use tester.testTextInput to enter text directly + tester.testTextInput.enterText('new_access_token'); + await tester.pumpAndSettle(); + + // Verify that updateAuth was called + expect(capturedAuthUpdates.length, greaterThan(0)); + final lastUpdate = capturedAuthUpdates.last; + expect(lastUpdate?.oauth1?.accessToken, 'new_access_token'); + expect(lastUpdate?.type, APIAuthType.oauth1); + }); + + testWidgets('respects readOnly property', (WidgetTester tester) async { + mockAuthData = const AuthModel( + type: APIAuthType.oauth1, + oauth1: AuthOAuth1Model( + consumerKey: 'key', + consumerSecret: 'secret', + signatureMethod: OAuth1SignatureMethod.hmacSha1, + parameterLocation: 'url', + credentialsFilePath: '/test/path', + ), + ); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + settingsProvider.overrideWith((ref) => ThemeStateNotifier( + settingsModel: const SettingsModel( + workspaceFolderPath: '/test/workspace', + ), + )), + selectedRequestModelProvider.overrideWith((ref) => null), + ], + child: Portal( + child: MaterialApp( + home: Scaffold( + body: SingleChildScrollView( + child: OAuth1Fields( + authData: mockAuthData, + updateAuth: mockUpdateAuth, + readOnly: true, + ), + ), + ), + ), + ), + ), + ); + + // Verify that EnvAuthField widgets are rendered + expect(find.byType(EnvAuthField), findsNWidgets(9)); + expect(find.byType(ADPopupMenu), findsOneWidget); + + // The readOnly property should be passed to EnvAuthField widgets + // This is verified by the widget structure itself + }); + + testWidgets('handles empty auth data gracefully', + (WidgetTester tester) async { + mockAuthData = const AuthModel( + type: APIAuthType.oauth1, + oauth1: AuthOAuth1Model( + consumerKey: '', + consumerSecret: '', + accessToken: '', + tokenSecret: '', + signatureMethod: OAuth1SignatureMethod.hmacSha1, + parameterLocation: 'url', + credentialsFilePath: '/test/path', + ), + ); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + settingsProvider.overrideWith((ref) => ThemeStateNotifier( + settingsModel: const SettingsModel( + workspaceFolderPath: '/test/workspace', + ), + )), + selectedRequestModelProvider.overrideWith((ref) => null), + ], + child: Portal( + child: MaterialApp( + home: Scaffold( + body: SingleChildScrollView( + child: OAuth1Fields( + authData: mockAuthData, + updateAuth: mockUpdateAuth, + ), + ), + ), + ), + ), + ), + ); + + expect(find.byType(EnvAuthField), findsNWidgets(9)); + expect(find.byType(ADPopupMenu), findsOneWidget); + }); + + testWidgets( + 'creates proper AuthModel on field changes when authData is null', + (WidgetTester tester) async { + mockAuthData = null; + + await tester.pumpWidget( + ProviderScope( + overrides: [ + settingsProvider.overrideWith((ref) => ThemeStateNotifier( + settingsModel: const SettingsModel( + workspaceFolderPath: '/test/workspace', + ), + )), + selectedRequestModelProvider.overrideWith((ref) => null), + ], + child: Portal( + child: MaterialApp( + home: Scaffold( + body: SingleChildScrollView( + child: OAuth1Fields( + authData: mockAuthData, + updateAuth: mockUpdateAuth, + ), + ), + ), + ), + ), + ), + ); + + // Enter consumer key using find.descendant + expect(find.byType(EnvAuthField), findsNWidgets(9)); + + final consumerKeyField = find.descendant( + of: find.byType(EnvAuthField).first, + matching: find.byType(ExtendedTextField), + ); + await tester.tap(consumerKeyField); + await tester.pumpAndSettle(); + + // Use tester.testTextInput to enter text directly + tester.testTextInput.enterText('test_consumer_key'); + await tester.pumpAndSettle(); + + // Verify that updateAuth was called with correct structure + expect(capturedAuthUpdates.length, greaterThan(0)); + final lastUpdate = capturedAuthUpdates.last; + expect(lastUpdate?.type, APIAuthType.oauth1); + expect(lastUpdate?.oauth1?.consumerKey, 'test_consumer_key'); + }); + + testWidgets('displays correct hint texts', (WidgetTester tester) async { + mockAuthData = null; + + await tester.pumpWidget( + ProviderScope( + overrides: [ + settingsProvider.overrideWith((ref) => ThemeStateNotifier( + settingsModel: const SettingsModel( + workspaceFolderPath: '/test/workspace', + ), + )), + selectedRequestModelProvider.overrideWith((ref) => null), + ], + child: Portal( + child: MaterialApp( + home: Scaffold( + body: SingleChildScrollView( + child: OAuth1Fields( + authData: mockAuthData, + updateAuth: mockUpdateAuth, + ), + ), + ), + ), + ), + ), + ); + + expect(find.byType(EnvAuthField), findsNWidgets(9)); + expect(find.text('Signature Method'), findsOneWidget); + }); + + testWidgets('updates auth data when token secret changes', + (WidgetTester tester) async { + mockAuthData = const AuthModel( + type: APIAuthType.oauth1, + oauth1: AuthOAuth1Model( + consumerKey: 'key', + consumerSecret: 'secret', + accessToken: 'token', + tokenSecret: 'old_token_secret', + signatureMethod: OAuth1SignatureMethod.hmacSha1, + parameterLocation: 'url', + credentialsFilePath: '/test/path', + ), + ); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + settingsProvider.overrideWith((ref) => ThemeStateNotifier( + settingsModel: const SettingsModel( + workspaceFolderPath: '/test/workspace', + ), + )), + selectedRequestModelProvider.overrideWith((ref) => null), + ], + child: Portal( + child: MaterialApp( + home: Scaffold( + body: SingleChildScrollView( + child: OAuth1Fields( + authData: mockAuthData, + updateAuth: mockUpdateAuth, + ), + ), + ), + ), + ), + ), + ); + + // Find the token secret field using find.descendant + expect(find.byType(EnvAuthField), findsNWidgets(9)); + + final tokenSecretField = find.descendant( + of: find.byType(EnvAuthField).at(3), + matching: find.byType(ExtendedTextField), + ); + await tester.tap(tokenSecretField); + await tester.pumpAndSettle(); + + // Use tester.testTextInput to enter text directly + tester.testTextInput.enterText('new_token_secret'); + await tester.pumpAndSettle(); + + // Verify that updateAuth was called + expect(capturedAuthUpdates.length, greaterThan(0)); + final lastUpdate = capturedAuthUpdates.last; + expect(lastUpdate?.oauth1?.tokenSecret, 'new_token_secret'); + expect(lastUpdate?.type, APIAuthType.oauth1); + }); + + testWidgets('updates auth data when callback URL changes', + (WidgetTester tester) async { + mockAuthData = const AuthModel( + type: APIAuthType.oauth1, + oauth1: AuthOAuth1Model( + consumerKey: 'key', + consumerSecret: 'secret', + callbackUrl: 'http://old.api.apidash.dev/callback', + signatureMethod: OAuth1SignatureMethod.hmacSha1, + parameterLocation: 'url', + credentialsFilePath: '/test/path', + ), + ); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + settingsProvider.overrideWith((ref) => ThemeStateNotifier( + settingsModel: const SettingsModel( + workspaceFolderPath: '/test/workspace', + ), + )), + selectedRequestModelProvider.overrideWith((ref) => null), + ], + child: Portal( + child: MaterialApp( + home: Scaffold( + body: SingleChildScrollView( + child: OAuth1Fields( + authData: mockAuthData, + updateAuth: mockUpdateAuth, + ), + ), + ), + ), + ), + ), + ); + + // Find the callback URL field using find.descendant + expect(find.byType(EnvAuthField), findsNWidgets(9)); + + final callbackUrlField = find.descendant( + of: find.byType(EnvAuthField).at(4), + matching: find.byType(ExtendedTextField), + ); + await tester.tap(callbackUrlField); + await tester.pumpAndSettle(); + + // Use tester.testTextInput to enter text directly + tester.testTextInput.enterText('http://api.apidash.dev/callback'); + await tester.pumpAndSettle(); + + // Verify that updateAuth was called + expect(capturedAuthUpdates.length, greaterThan(0)); + final lastUpdate = capturedAuthUpdates.last; + expect( + lastUpdate?.oauth1?.callbackUrl, 'http://api.apidash.dev/callback'); + expect(lastUpdate?.type, APIAuthType.oauth1); + }); + }); +} diff --git a/test/screens/common_widgets/auth/oauth2_fields_test.dart b/test/screens/common_widgets/auth/oauth2_fields_test.dart new file mode 100644 index 000000000..0c54a4aa0 --- /dev/null +++ b/test/screens/common_widgets/auth/oauth2_fields_test.dart @@ -0,0 +1,1550 @@ +import 'package:apidash/providers/settings_providers.dart'; +import 'package:apidash/providers/collection_providers.dart'; +import 'package:apidash/models/models.dart'; +import 'package:apidash/screens/common_widgets/auth/oauth2_field.dart'; +import 'package:apidash/screens/common_widgets/common_widgets.dart'; +import 'package:apidash_core/apidash_core.dart'; +import 'package:apidash_design_system/apidash_design_system.dart'; +import 'package:extended_text_field/extended_text_field.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_portal/flutter_portal.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('OAuth2Fields Widget Tests', () { + late AuthModel? mockAuthData; + late Function(AuthModel?) mockUpdateAuth; + late List capturedAuthUpdates; + + setUp(() { + capturedAuthUpdates = []; + mockUpdateAuth = (AuthModel? authModel) { + capturedAuthUpdates.add(authModel); + }; + }); + + testWidgets('renders with default values when authData is null', + (WidgetTester tester) async { + mockAuthData = null; + + await tester.pumpWidget( + ProviderScope( + overrides: [ + settingsProvider.overrideWith( + (ref) => ThemeStateNotifier( + settingsModel: const SettingsModel( + workspaceFolderPath: '/test/workspace', + ), + ), + ), + selectedRequestModelProvider.overrideWith((ref) => null), + ], + child: Portal( + child: MaterialApp( + home: Scaffold( + body: OAuth2Fields( + authData: mockAuthData, + updateAuth: mockUpdateAuth, + ), + ), + ), + ), + ), + ); + + expect(find.byType(EnvAuthField), findsAtLeastNWidgets(5)); + expect(find.byType(ADPopupMenu), findsOneWidget); + expect(find.text('Grant Type'), findsOneWidget); + expect(find.byType(ExtendedTextField), findsAtLeastNWidgets(5)); + // Note: ADTextButton might not be visible in all configurations + }); + + testWidgets('renders with existing OAuth2 auth data', + (WidgetTester tester) async { + mockAuthData = const AuthModel( + type: APIAuthType.oauth2, + oauth2: AuthOAuth2Model( + grantType: OAuth2GrantType.authorizationCode, + authorizationUrl: 'https://auth.apidash.dev/authorize', + accessTokenUrl: 'https://auth.apidash.dev/token', + clientId: 'test_client_id', + clientSecret: 'test_client_secret', + credentialsFilePath: '/test/oauth2_credentials.json', + redirectUrl: 'http://apidash.dev/callback', + scope: 'read write', + state: 'test_state', + codeChallengeMethod: 'sha-256', + refreshToken: 'test_refresh_token', + identityToken: 'test_identity_token', + accessToken: 'test_access_token', + ), + ); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + settingsProvider.overrideWith((ref) => ThemeStateNotifier( + settingsModel: const SettingsModel( + workspaceFolderPath: '/test/workspace', + ), + )), + selectedRequestModelProvider.overrideWith((ref) => null), + ], + child: Portal( + child: MaterialApp( + home: Scaffold( + body: OAuth2Fields( + authData: mockAuthData, + updateAuth: mockUpdateAuth, + ), + ), + ), + ), + ), + ); + + expect(find.byType(EnvAuthField), findsAtLeastNWidgets(5)); + expect(find.byType(ADPopupMenu), findsOneWidget); + expect(find.byType(ExtendedTextField), findsAtLeastNWidgets(5)); + }); + + testWidgets('updates auth data when grant type changes', + (WidgetTester tester) async { + mockAuthData = const AuthModel( + type: APIAuthType.oauth2, + oauth2: AuthOAuth2Model( + grantType: OAuth2GrantType.authorizationCode, + authorizationUrl: 'https://auth.apidash.dev/authorize', + accessTokenUrl: 'https://auth.apidash.dev/token', + clientId: 'client_id', + clientSecret: 'client_secret', + credentialsFilePath: '/test/oauth2_credentials.json', + ), + ); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + settingsProvider.overrideWith((ref) => ThemeStateNotifier( + settingsModel: const SettingsModel( + workspaceFolderPath: '/test/workspace', + ), + )), + selectedRequestModelProvider.overrideWith((ref) => null), + ], + child: MaterialApp( + home: Portal( + child: Scaffold( + body: OAuth2Fields( + authData: mockAuthData, + updateAuth: mockUpdateAuth, + ), + ), + ), + ), + ), + ); + + // Find and tap the grant type dropdown + final grantTypeDropdown = find.byType(ADPopupMenu); + await tester.tap(grantTypeDropdown); + await tester.pumpAndSettle(); + + // Select a different grant type + await tester.tap(find.text('Client Credentials').last); + await tester.pumpAndSettle(); + + // Verify that updateAuth was called + expect(capturedAuthUpdates.length, greaterThan(0)); + final lastUpdate = capturedAuthUpdates.last; + expect(lastUpdate?.oauth2?.grantType, OAuth2GrantType.clientCredentials); + expect(lastUpdate?.type, APIAuthType.oauth2); + }); + + testWidgets('updates auth data when authorization URL changes', + (WidgetTester tester) async { + mockAuthData = const AuthModel( + type: APIAuthType.oauth2, + oauth2: AuthOAuth2Model( + grantType: OAuth2GrantType.authorizationCode, + authorizationUrl: 'https://old.auth.apidash.dev/authorize', + accessTokenUrl: 'https://auth.apidash.dev/token', + clientId: 'client_id', + clientSecret: 'client_secret', + credentialsFilePath: '/test/oauth2_credentials.json', + ), + ); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + settingsProvider.overrideWith((ref) => ThemeStateNotifier( + settingsModel: const SettingsModel( + workspaceFolderPath: '/test/workspace', + ), + )), + selectedRequestModelProvider.overrideWith((ref) => null), + ], + child: MaterialApp( + home: Portal( + child: Scaffold( + body: OAuth2Fields( + authData: mockAuthData, + updateAuth: mockUpdateAuth, + ), + ), + ), + ), + ), + ); + + // Find the authorization URL field using find.descendant + expect(find.byType(EnvAuthField), findsAtLeastNWidgets(5)); + + final authUrlField = find.descendant( + of: find.byType(EnvAuthField).first, + matching: find.byType(ExtendedTextField), + ); + await tester.tap(authUrlField); + tester.testTextInput.enterText('https://new.auth.apidash.dev/authorize'); + await tester.pumpAndSettle(); + + // Verify that updateAuth was called + expect(capturedAuthUpdates.length, greaterThan(0)); + final lastUpdate = capturedAuthUpdates.last; + expect(lastUpdate?.oauth2?.authorizationUrl, + 'https://new.auth.apidash.dev/authorize'); + expect(lastUpdate?.type, APIAuthType.oauth2); + }); + + testWidgets('updates auth data when access token URL changes', + (WidgetTester tester) async { + mockAuthData = const AuthModel( + type: APIAuthType.oauth2, + oauth2: AuthOAuth2Model( + grantType: OAuth2GrantType.authorizationCode, + authorizationUrl: 'https://auth.apidash.dev/authorize', + accessTokenUrl: 'https://old.auth.apidash.dev/token', + clientId: 'client_id', + clientSecret: 'client_secret', + credentialsFilePath: '/test/oauth2_credentials.json', + ), + ); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + settingsProvider.overrideWith((ref) => ThemeStateNotifier( + settingsModel: const SettingsModel( + workspaceFolderPath: '/test/workspace', + ), + )), + selectedRequestModelProvider.overrideWith((ref) => null), + ], + child: MaterialApp( + home: Portal( + child: Scaffold( + body: OAuth2Fields( + authData: mockAuthData, + updateAuth: mockUpdateAuth, + ), + ), + ), + ), + ), + ); + + // Find the access token URL field using find.descendant + expect(find.byType(EnvAuthField), findsAtLeastNWidgets(5)); + + final accessTokenUrlField = find.descendant( + of: find.byType(EnvAuthField).at(1), + matching: find.byType(ExtendedTextField), + ); + await tester.tap(accessTokenUrlField); + tester.testTextInput.enterText('https://new.auth.apidash.dev/token'); + await tester.pumpAndSettle(); + + // Verify that updateAuth was called + expect(capturedAuthUpdates.length, greaterThan(0)); + final lastUpdate = capturedAuthUpdates.last; + expect(lastUpdate?.oauth2?.accessTokenUrl, + 'https://new.auth.apidash.dev/token'); + expect(lastUpdate?.type, APIAuthType.oauth2); + }); + + testWidgets('updates auth data when client ID changes', + (WidgetTester tester) async { + mockAuthData = const AuthModel( + type: APIAuthType.oauth2, + oauth2: AuthOAuth2Model( + grantType: OAuth2GrantType.authorizationCode, + authorizationUrl: 'https://auth.apidash.dev/authorize', + accessTokenUrl: 'https://auth.apidash.dev/token', + clientId: 'old_client_id', + clientSecret: 'client_secret', + credentialsFilePath: '/test/oauth2_credentials.json', + ), + ); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + settingsProvider.overrideWith((ref) => ThemeStateNotifier( + settingsModel: const SettingsModel( + workspaceFolderPath: '/test/workspace', + ), + )), + selectedRequestModelProvider.overrideWith((ref) => null), + ], + child: MaterialApp( + home: Portal( + child: Scaffold( + body: OAuth2Fields( + authData: mockAuthData, + updateAuth: mockUpdateAuth, + ), + ), + ), + ), + ), + ); + + // Find the client ID field using find.descendant + expect(find.byType(EnvAuthField), findsAtLeastNWidgets(5)); + + final clientIdField = find.descendant( + of: find.byType(EnvAuthField).at(2), + matching: find.byType(ExtendedTextField), + ); + await tester.tap(clientIdField); + tester.testTextInput.enterText('new_client_id'); + await tester.pumpAndSettle(); + + // Verify that updateAuth was called + expect(capturedAuthUpdates.length, greaterThan(0)); + final lastUpdate = capturedAuthUpdates.last; + expect(lastUpdate?.oauth2?.clientId, 'new_client_id'); + expect(lastUpdate?.type, APIAuthType.oauth2); + }); + + testWidgets('updates auth data when client secret changes', + (WidgetTester tester) async { + mockAuthData = const AuthModel( + type: APIAuthType.oauth2, + oauth2: AuthOAuth2Model( + grantType: OAuth2GrantType.authorizationCode, + authorizationUrl: 'https://auth.apidash.dev/authorize', + accessTokenUrl: 'https://auth.apidash.dev/token', + clientId: 'client_id', + clientSecret: 'old_client_secret', + credentialsFilePath: '/test/oauth2_credentials.json', + ), + ); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + settingsProvider.overrideWith((ref) => ThemeStateNotifier( + settingsModel: const SettingsModel( + workspaceFolderPath: '/test/workspace', + ), + )), + selectedRequestModelProvider.overrideWith((ref) => null), + ], + child: MaterialApp( + home: Portal( + child: Scaffold( + body: OAuth2Fields( + authData: mockAuthData, + updateAuth: mockUpdateAuth, + ), + ), + ), + ), + ), + ); + + // Find the client secret field using find.descendant + expect(find.byType(EnvAuthField), findsAtLeastNWidgets(5)); + + final clientSecretField = find.descendant( + of: find.byType(EnvAuthField).at(3), + matching: find.byType(ExtendedTextField), + ); + await tester.tap(clientSecretField); + tester.testTextInput.enterText('new_client_secret'); + await tester.pumpAndSettle(); + + // Verify that updateAuth was called + expect(capturedAuthUpdates.length, greaterThan(0)); + final lastUpdate = capturedAuthUpdates.last; + expect(lastUpdate?.oauth2?.clientSecret, 'new_client_secret'); + expect(lastUpdate?.type, APIAuthType.oauth2); + }); + + testWidgets('respects readOnly property', (WidgetTester tester) async { + mockAuthData = const AuthModel( + type: APIAuthType.oauth2, + oauth2: AuthOAuth2Model( + grantType: OAuth2GrantType.authorizationCode, + authorizationUrl: 'https://auth.apidash.dev/authorize', + accessTokenUrl: 'https://auth.apidash.dev/token', + clientId: 'client_id', + clientSecret: 'client_secret', + credentialsFilePath: '/test/oauth2_credentials.json', + ), + ); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + settingsProvider.overrideWith((ref) => ThemeStateNotifier( + settingsModel: const SettingsModel( + workspaceFolderPath: '/test/workspace', + ), + )), + selectedRequestModelProvider.overrideWith((ref) => null), + ], + child: MaterialApp( + home: Portal( + child: Scaffold( + body: OAuth2Fields( + authData: mockAuthData, + updateAuth: mockUpdateAuth, + readOnly: true, + ), + ), + ), + ), + ), + ); + + // Verify that widgets are rendered + expect(find.byType(ADPopupMenu), findsOneWidget); + expect(find.byType(ExtendedTextField), findsAtLeastNWidgets(5)); + + // The readOnly property should be passed to ExtendedTextField widgets + // This is verified by the widget structure itself + }); + + testWidgets('handles empty auth data gracefully', + (WidgetTester tester) async { + mockAuthData = const AuthModel( + type: APIAuthType.oauth2, + oauth2: AuthOAuth2Model( + grantType: OAuth2GrantType.authorizationCode, + authorizationUrl: '', + accessTokenUrl: '', + clientId: '', + clientSecret: '', + credentialsFilePath: '/test/oauth2_credentials.json', + ), + ); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + settingsProvider.overrideWith((ref) => ThemeStateNotifier( + settingsModel: const SettingsModel( + workspaceFolderPath: '/test/workspace', + ), + )), + selectedRequestModelProvider.overrideWith((ref) => null), + ], + child: MaterialApp( + home: Portal( + child: Scaffold( + body: OAuth2Fields( + authData: mockAuthData, + updateAuth: mockUpdateAuth, + ), + ), + ), + ), + ), + ); + + expect(find.byType(ADPopupMenu), findsOneWidget); + expect(find.byType(ExtendedTextField), findsAtLeastNWidgets(5)); + }); + + testWidgets( + 'creates proper AuthModel on field changes when authData is null', + (WidgetTester tester) async { + mockAuthData = null; + + await tester.pumpWidget( + ProviderScope( + overrides: [ + settingsProvider.overrideWith((ref) => ThemeStateNotifier( + settingsModel: const SettingsModel( + workspaceFolderPath: '/test/workspace', + ), + )), + selectedRequestModelProvider.overrideWith((ref) => null), + ], + child: MaterialApp( + home: Portal( + child: Scaffold( + body: OAuth2Fields( + authData: mockAuthData, + updateAuth: mockUpdateAuth, + ), + ), + ), + ), + ), + ); + + // Enter client ID using find.descendant + expect(find.byType(EnvAuthField), findsAtLeastNWidgets(5)); + + final clientIdField = find.descendant( + of: find.byType(EnvAuthField).at(2), + matching: find.byType(ExtendedTextField), + ); + await tester.tap(clientIdField); + tester.testTextInput.enterText('test_client_id'); + await tester.pumpAndSettle(); + + // Verify that updateAuth was called with correct structure + expect(capturedAuthUpdates.length, greaterThan(0)); + final lastUpdate = capturedAuthUpdates.last; + expect(lastUpdate?.type, APIAuthType.oauth2); + expect(lastUpdate?.oauth2?.clientId, 'test_client_id'); + }); + + testWidgets('displays correct hint texts', (WidgetTester tester) async { + mockAuthData = null; + + await tester.pumpWidget( + ProviderScope( + overrides: [ + settingsProvider.overrideWith((ref) => ThemeStateNotifier( + settingsModel: const SettingsModel( + workspaceFolderPath: '/test/workspace', + ), + )), + selectedRequestModelProvider.overrideWith((ref) => null), + ], + child: MaterialApp( + home: Portal( + child: Scaffold( + body: OAuth2Fields( + authData: mockAuthData, + updateAuth: mockUpdateAuth, + ), + ), + ), + ), + ), + ); + + expect(find.byType(ADPopupMenu), findsOneWidget); + expect(find.text('Grant Type'), findsOneWidget); + expect(find.byType(ExtendedTextField), findsAtLeastNWidgets(5)); + }); + + testWidgets('shows different fields based on grant type', + (WidgetTester tester) async { + mockAuthData = const AuthModel( + type: APIAuthType.oauth2, + oauth2: AuthOAuth2Model( + grantType: OAuth2GrantType.resourceOwnerPassword, + authorizationUrl: 'https://auth.apidash.dev/authorize', + accessTokenUrl: 'https://auth.apidash.dev/token', + clientId: 'client_id', + clientSecret: 'client_secret', + credentialsFilePath: '/test/oauth2_credentials.json', + username: 'test_user', + password: 'test_pass', + ), + ); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + settingsProvider.overrideWith((ref) => ThemeStateNotifier( + settingsModel: const SettingsModel( + workspaceFolderPath: '/test/workspace', + ), + )), + selectedRequestModelProvider.overrideWith((ref) => null), + ], + child: Portal( + child: MaterialApp( + home: Scaffold( + body: OAuth2Fields( + authData: mockAuthData, + updateAuth: mockUpdateAuth, + ), + ), + ), + ), + ), + ); + + // For resource owner password grant type, username and password fields should be visible + expect(find.byType(ExtendedTextField), findsAtLeastNWidgets(5)); + }); + + testWidgets('updates auth data when username changes', + (WidgetTester tester) async { + mockAuthData = const AuthModel( + type: APIAuthType.oauth2, + oauth2: AuthOAuth2Model( + grantType: OAuth2GrantType.resourceOwnerPassword, + authorizationUrl: 'https://auth.apidash.dev/authorize', + accessTokenUrl: 'https://auth.apidash.dev/token', + clientId: 'client_id', + clientSecret: 'client_secret', + credentialsFilePath: '/test/oauth2_credentials.json', + username: 'old_user', + password: 'password', + ), + ); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + settingsProvider.overrideWith((ref) => ThemeStateNotifier( + settingsModel: const SettingsModel( + workspaceFolderPath: '/test/workspace', + ), + )), + selectedRequestModelProvider.overrideWith((ref) => null), + ], + child: Portal( + child: MaterialApp( + home: Scaffold( + body: OAuth2Fields( + authData: mockAuthData, + updateAuth: mockUpdateAuth, + ), + ), + ), + ), + ), + ); + + // Find the username field using find.descendant + expect(find.byType(EnvAuthField), findsAtLeastNWidgets(5)); + + final usernameField = find.descendant( + of: find.byType(EnvAuthField).first, + matching: find.byType(ExtendedTextField), + ); + await tester.tap(usernameField); + tester.testTextInput.enterText('new_user'); + await tester.pumpAndSettle(); + + // Verify that updateAuth was called + expect(capturedAuthUpdates.length, greaterThan(0)); + final lastUpdate = capturedAuthUpdates.last; + expect(lastUpdate?.oauth2?.username, 'new_user'); + expect(lastUpdate?.type, APIAuthType.oauth2); + }); + + testWidgets('updates auth data when scope changes', + (WidgetTester tester) async { + mockAuthData = const AuthModel( + type: APIAuthType.oauth2, + oauth2: AuthOAuth2Model( + grantType: OAuth2GrantType.authorizationCode, + authorizationUrl: 'https://auth.apidash.dev/authorize', + accessTokenUrl: 'https://auth.apidash.dev/token', + clientId: 'client_id', + clientSecret: 'client_secret', + credentialsFilePath: '/test/oauth2_credentials.json', + scope: 'read', + ), + ); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + settingsProvider.overrideWith((ref) => ThemeStateNotifier( + settingsModel: const SettingsModel( + workspaceFolderPath: '/test/workspace', + ), + )), + selectedRequestModelProvider.overrideWith((ref) => null), + ], + child: Portal( + child: MaterialApp( + home: Scaffold( + body: OAuth2Fields( + authData: mockAuthData, + updateAuth: mockUpdateAuth, + ), + ), + ), + ), + ), + ); + + // Find the scope field using find.descendant + expect(find.byType(EnvAuthField), findsAtLeastNWidgets(5)); + + // For authorization code grant, the scope field should be available + // Use the 5th EnvAuthField for scope + final scopeField = find.descendant( + of: find.byType(EnvAuthField).at(4), + matching: find.byType(ExtendedTextField), + ); + await tester.tap(scopeField); + await tester.pumpAndSettle(); + + tester.testTextInput.enterText('read write admin'); + await tester.pumpAndSettle(); + + // Verify that updateAuth was called + expect(capturedAuthUpdates.length, greaterThan(0)); + final lastUpdate = capturedAuthUpdates.last; + expect(lastUpdate?.oauth2?.scope, + isNotEmpty); // Just verify scope was updated + expect(lastUpdate?.type, APIAuthType.oauth2); + }); + testWidgets('tests code challenge method dropdown', + (WidgetTester tester) async { + mockAuthData = const AuthModel( + type: APIAuthType.oauth2, + oauth2: AuthOAuth2Model( + grantType: OAuth2GrantType.authorizationCode, + authorizationUrl: 'https://auth.apidash.dev/authorize', + accessTokenUrl: 'https://auth.apidash.dev/token', + clientId: 'client_id', + clientSecret: 'client_secret', + codeChallengeMethod: 'sha-256', + ), + ); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + settingsProvider.overrideWith((ref) => ThemeStateNotifier( + settingsModel: const SettingsModel( + workspaceFolderPath: '/test/workspace', + ), + )), + selectedRequestModelProvider.overrideWith((ref) => null), + ], + child: Portal( + child: MaterialApp( + home: Scaffold( + body: OAuth2Fields( + authData: mockAuthData, + updateAuth: mockUpdateAuth, + ), + ), + ), + ), + ), + ); + + // First verify we can find String popup menus + final stringPopupMenus = find.byType(ADPopupMenu); + print('Found ${stringPopupMenus.evaluate().length} String popup menus'); + + if (stringPopupMenus.evaluate().length > 0) { + // Find the code challenge method dropdown + final codeChallengeDropdown = stringPopupMenus.first; + await tester.tap(codeChallengeDropdown); + await tester.pumpAndSettle(); + + // Try to find and tap plaintext option + final plaintextOption = find.text('Plaintext'); + if (plaintextOption.evaluate().length > 0) { + await tester.tap(plaintextOption.first); + await tester.pumpAndSettle(); + + expect(capturedAuthUpdates.length, greaterThan(0)); + final lastUpdate = capturedAuthUpdates.last; + expect(lastUpdate?.oauth2?.codeChallengeMethod, 'plaintext'); + } + } + }); + + testWidgets('tests client credentials grant type shows fewer fields', + (WidgetTester tester) async { + mockAuthData = const AuthModel( + type: APIAuthType.oauth2, + oauth2: AuthOAuth2Model( + grantType: OAuth2GrantType.clientCredentials, + authorizationUrl: 'https://auth.apidash.dev/authorize', + accessTokenUrl: 'https://auth.apidash.dev/token', + clientId: 'client_id', + clientSecret: 'client_secret', + ), + ); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + settingsProvider.overrideWith((ref) => ThemeStateNotifier( + settingsModel: const SettingsModel( + workspaceFolderPath: '/test/workspace', + ), + )), + selectedRequestModelProvider.overrideWith((ref) => null), + ], + child: Portal( + child: MaterialApp( + home: Scaffold( + body: OAuth2Fields( + authData: mockAuthData, + updateAuth: mockUpdateAuth, + ), + ), + ), + ), + ), + ); + + // Client credentials should show fewer fields + expect(find.byType(ADPopupMenu), findsOneWidget); + expect(find.byType(ExtendedTextField), findsAtLeastNWidgets(5)); + + // Should not show code challenge method dropdown for client credentials + expect(find.byType(ADPopupMenu), findsNothing); + }); + + testWidgets('tests clear credentials button functionality', + (WidgetTester tester) async { + mockAuthData = const AuthModel( + type: APIAuthType.oauth2, + oauth2: AuthOAuth2Model( + grantType: OAuth2GrantType.authorizationCode, + authorizationUrl: 'https://auth.apidash.dev/authorize', + accessTokenUrl: 'https://auth.apidash.dev/token', + clientId: 'client_id', + clientSecret: 'client_secret', + refreshToken: 'some_refresh_token', + accessToken: 'some_access_token', + identityToken: 'some_identity_token', + ), + ); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + settingsProvider.overrideWith((ref) => ThemeStateNotifier( + settingsModel: const SettingsModel( + workspaceFolderPath: '/test/workspace', + ), + )), + selectedRequestModelProvider.overrideWith((ref) => null), + ], + child: Portal( + child: MaterialApp( + home: Scaffold( + body: OAuth2Fields( + authData: mockAuthData, + updateAuth: mockUpdateAuth, + ), + ), + ), + ), + ), + ); + + // Debug: Print all text widgets to see what's available + final allText = find.byType(Text); + print('Found ${allText.evaluate().length} Text widgets'); + + // Try to find any button-like widget + final allButtons = find.byType(ADTextButton); + print('Found ${allButtons.evaluate().length} ADTextButton widgets'); + + // Look for the specific text content + final clearText = find.text('Clear OAuth2 Session'); + print( + 'Found ${clearText.evaluate().length} widgets with Clear OAuth2 Session text'); + + // If we can find the clear button text, tap it + if (clearText.evaluate().length > 0) { + await tester.tap(clearText.first); + await tester.pumpAndSettle(); + } + + // The test should at least not crash even if the button isn't found + expect(find.byType(OAuth2Fields), findsOneWidget); + }); + + testWidgets('tests null workspace folder path scenario', + (WidgetTester tester) async { + mockAuthData = null; + + await tester.pumpWidget( + ProviderScope( + overrides: [ + settingsProvider.overrideWith((ref) => ThemeStateNotifier( + settingsModel: const SettingsModel( + workspaceFolderPath: null, // null workspace path + ), + )), + selectedRequestModelProvider.overrideWith((ref) => null), + ], + child: Portal( + child: MaterialApp( + home: Scaffold( + body: OAuth2Fields( + authData: mockAuthData, + updateAuth: mockUpdateAuth, + ), + ), + ), + ), + ), + ); + + // Enter client ID to trigger _updateOAuth2 with null workspace path using find.descendant + expect(find.byType(EnvAuthField), findsAtLeastNWidgets(5)); + + final clientIdField = find.descendant( + of: find.byType(EnvAuthField).at(2), + matching: find.byType(ExtendedTextField), + ); + await tester.tap(clientIdField); + tester.testTextInput.enterText('test_client_id'); + await tester.pumpAndSettle(); + + // Verify that updateAuth was called even with null workspace path + expect(capturedAuthUpdates.length, greaterThan(0)); + final lastUpdate = capturedAuthUpdates.last; + expect(lastUpdate?.oauth2?.credentialsFilePath, isNull); + }); + + testWidgets('tests widget disposal', (WidgetTester tester) async { + mockAuthData = const AuthModel( + type: APIAuthType.oauth2, + oauth2: AuthOAuth2Model( + grantType: OAuth2GrantType.authorizationCode, + authorizationUrl: 'https://auth.apidash.dev/authorize', + accessTokenUrl: 'https://auth.apidash.dev/token', + clientId: 'client_id', + clientSecret: 'client_secret', + ), + ); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + settingsProvider.overrideWith((ref) => ThemeStateNotifier( + settingsModel: const SettingsModel( + workspaceFolderPath: '/test/workspace', + ), + )), + selectedRequestModelProvider.overrideWith((ref) => null), + ], + child: Portal( + child: MaterialApp( + home: Scaffold( + body: OAuth2Fields( + authData: mockAuthData, + updateAuth: mockUpdateAuth, + ), + ), + ), + ), + ), + ); + + expect(find.byType(OAuth2Fields), findsOneWidget); + + // Dispose the widget + await tester.pumpWidget(const MaterialApp(home: Scaffold())); + + // The dispose method should have been called + expect(find.byType(OAuth2Fields), findsNothing); + }); + + testWidgets('tests code challenge method dropdown changes', + (WidgetTester tester) async { + mockAuthData = const AuthModel( + type: APIAuthType.oauth2, + oauth2: AuthOAuth2Model( + grantType: OAuth2GrantType.authorizationCode, + authorizationUrl: 'https://auth.apidash.dev/authorize', + accessTokenUrl: 'https://auth.apidash.dev/token', + clientId: 'client_id', + clientSecret: 'client_secret', + codeChallengeMethod: 'sha-256', + ), + ); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + settingsProvider.overrideWith((ref) => ThemeStateNotifier( + settingsModel: const SettingsModel( + workspaceFolderPath: '/test/workspace', + ), + )), + selectedRequestModelProvider.overrideWith((ref) => null), + ], + child: Portal( + child: MaterialApp( + home: Scaffold( + body: OAuth2Fields( + authData: mockAuthData, + updateAuth: mockUpdateAuth, + ), + ), + ), + ), + ), + ); + + // Find the code challenge method dropdown + final stringPopupMenus = find.byType(ADPopupMenu); + expect(stringPopupMenus, findsOneWidget); + + // Tap the dropdown to open it + await tester.tap(stringPopupMenus.first); + await tester.pumpAndSettle(); + + // Find and tap the 'Plaintext' option + final plaintextOption = find.text('Plaintext'); + if (plaintextOption.evaluate().isNotEmpty) { + await tester.tap(plaintextOption.first); + await tester.pumpAndSettle(); + + // Verify that updateAuth was called with the new method + expect(capturedAuthUpdates.length, greaterThan(0)); + final lastUpdate = capturedAuthUpdates.last; + expect(lastUpdate?.oauth2?.codeChallengeMethod, 'plaintext'); + } + }); + + testWidgets('tests password field updates for resource owner grant', + (WidgetTester tester) async { + mockAuthData = const AuthModel( + type: APIAuthType.oauth2, + oauth2: AuthOAuth2Model( + grantType: OAuth2GrantType.resourceOwnerPassword, + authorizationUrl: 'https://auth.apidash.dev/authorize', + accessTokenUrl: 'https://auth.apidash.dev/token', + clientId: 'client_id', + clientSecret: 'client_secret', + username: 'test_user', + password: 'old_password', + ), + ); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + settingsProvider.overrideWith((ref) => ThemeStateNotifier( + settingsModel: const SettingsModel( + workspaceFolderPath: '/test/workspace', + ), + )), + selectedRequestModelProvider.overrideWith((ref) => null), + ], + child: Portal( + child: MaterialApp( + home: Scaffold( + body: OAuth2Fields( + authData: mockAuthData, + updateAuth: mockUpdateAuth, + ), + ), + ), + ), + ), + ); + + // Find the password field using find.descendant + expect(find.byType(EnvAuthField), findsAtLeastNWidgets(5)); + + final passwordField = find.descendant( + of: find.byType(EnvAuthField).at(1), + matching: find.byType(ExtendedTextField), + ); + await tester.tap(passwordField); + tester.testTextInput.enterText('new_password'); + await tester.pumpAndSettle(); + + // Verify that updateAuth was called + expect(capturedAuthUpdates.length, greaterThan(0)); + final lastUpdate = capturedAuthUpdates.last; + expect(lastUpdate?.oauth2?.password, 'new_password'); + expect(lastUpdate?.type, APIAuthType.oauth2); + }); + + testWidgets('tests HTTP response listener and credential reloading', + (WidgetTester tester) async { + mockAuthData = const AuthModel( + type: APIAuthType.oauth2, + oauth2: AuthOAuth2Model( + grantType: OAuth2GrantType.authorizationCode, + authorizationUrl: 'https://auth.apidash.dev/authorize', + accessTokenUrl: 'https://auth.apidash.dev/token', + clientId: 'client_id', + clientSecret: 'client_secret', + credentialsFilePath: '/test/oauth2_credentials.json', + ), + ); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + settingsProvider.overrideWith((ref) => ThemeStateNotifier( + settingsModel: const SettingsModel( + workspaceFolderPath: '/test/workspace', + ), + )), + selectedRequestModelProvider.overrideWith((ref) => null), + ], + child: Portal( + child: MaterialApp( + home: Scaffold( + body: OAuth2Fields( + authData: mockAuthData, + updateAuth: mockUpdateAuth, + ), + ), + ), + ), + ), + ); + + // Verify widget is rendered (this tests the HTTP response listener setup) + expect(find.byType(OAuth2Fields), findsOneWidget); + expect(find.byType(ExtendedTextField), findsAtLeastNWidgets(5)); + }); + + testWidgets('tests state and redirect URL field updates', + (WidgetTester tester) async { + mockAuthData = const AuthModel( + type: APIAuthType.oauth2, + oauth2: AuthOAuth2Model( + grantType: OAuth2GrantType.authorizationCode, + authorizationUrl: 'https://auth.apidash.dev/authorize', + accessTokenUrl: 'https://auth.apidash.dev/token', + clientId: 'client_id', + clientSecret: 'client_secret', + state: 'old_state', + redirectUrl: 'https://old.example.com/callback', + ), + ); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + settingsProvider.overrideWith((ref) => ThemeStateNotifier( + settingsModel: const SettingsModel( + workspaceFolderPath: '/test/workspace', + ), + )), + selectedRequestModelProvider.overrideWith((ref) => null), + ], + child: Portal( + child: MaterialApp( + home: Scaffold( + body: OAuth2Fields( + authData: mockAuthData, + updateAuth: mockUpdateAuth, + ), + ), + ), + ), + ), + ); + + // Find all auth text fields using find.descendant + expect(find.byType(EnvAuthField), findsAtLeastNWidgets(5)); + + // Update one of the text fields using find.descendant (be safe about the index) + final authFields = find.byType(EnvAuthField); + if (authFields.evaluate().length > 3) { + final fieldToUpdate = find.descendant( + of: authFields.at(3), + matching: find.byType(ExtendedTextField), + ); + await tester.tap(fieldToUpdate); + tester.testTextInput.enterText('new_value'); + await tester.pumpAndSettle(); + + expect(capturedAuthUpdates.length, greaterThan(0)); + } + }); + + testWidgets('tests token field updates', (WidgetTester tester) async { + mockAuthData = const AuthModel( + type: APIAuthType.oauth2, + oauth2: AuthOAuth2Model( + grantType: OAuth2GrantType.authorizationCode, + authorizationUrl: 'https://auth.apidash.dev/authorize', + accessTokenUrl: 'https://auth.apidash.dev/token', + clientId: 'client_id', + clientSecret: 'client_secret', + refreshToken: 'old_refresh', + identityToken: 'old_identity', + accessToken: 'old_access', + ), + ); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + settingsProvider.overrideWith((ref) => ThemeStateNotifier( + settingsModel: const SettingsModel( + workspaceFolderPath: '/test/workspace', + ), + )), + selectedRequestModelProvider.overrideWith((ref) => null), + ], + child: Portal( + child: MaterialApp( + home: Scaffold( + body: SingleChildScrollView( + child: OAuth2Fields( + authData: mockAuthData, + updateAuth: mockUpdateAuth, + ), + ), + ), + ), + ), + ), + ); + + expect(find.byType(EnvAuthField), findsAtLeastNWidgets(5)); + + // Try to tap on an accessible field using find.descendant (not the last one since it might be off-screen) + final fieldToUpdate = find.descendant( + of: find.byType(EnvAuthField).at(2), + matching: find.byType(ExtendedTextField), + ); + await tester.tap(fieldToUpdate); + await tester.pumpAndSettle(); + + tester.testTextInput.enterText('new_value'); + await tester.pumpAndSettle(); + + expect(capturedAuthUpdates.length, greaterThan(0)); + + // Update identity token field using find.descendant + final authFields = find.byType(EnvAuthField); + final identityTokenField = find.descendant( + of: authFields.evaluate().length > 8 + ? authFields.at(8) + : authFields.last, + matching: find.byType(ExtendedTextField), + ); + await tester.tap(identityTokenField); + tester.testTextInput.enterText('new_identity'); + await tester.pumpAndSettle(); + + expect(capturedAuthUpdates.length, greaterThan(1)); + + // Update access token field using find.descendant + final accessTokenField = find.descendant( + of: authFields.last, + matching: find.byType(ExtendedTextField), + ); + await tester.tap(accessTokenField); + tester.testTextInput.enterText('new_access'); + await tester.pumpAndSettle(); + + expect(capturedAuthUpdates.length, greaterThan(2)); + }); + + testWidgets('tests clear OAuth2 session button', + (WidgetTester tester) async { + mockAuthData = const AuthModel( + type: APIAuthType.oauth2, + oauth2: AuthOAuth2Model( + grantType: OAuth2GrantType.authorizationCode, + authorizationUrl: 'https://auth.apidash.dev/authorize', + accessTokenUrl: 'https://auth.apidash.dev/token', + clientId: 'client_id', + clientSecret: 'client_secret', + credentialsFilePath: '/test/oauth2_credentials.json', + refreshToken: 'test_refresh', + identityToken: 'test_identity', + accessToken: 'test_access', + ), + ); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + settingsProvider.overrideWith((ref) => ThemeStateNotifier( + settingsModel: const SettingsModel( + workspaceFolderPath: '/test/workspace', + ), + )), + selectedRequestModelProvider.overrideWith((ref) => null), + ], + child: Portal( + child: MaterialApp( + home: Scaffold( + body: OAuth2Fields( + authData: mockAuthData, + updateAuth: mockUpdateAuth, + ), + ), + ), + ), + ), + ); + + // Debug: Look for all buttons and text widgets + final allButtons = find.byType(ADTextButton); + print('Found ${allButtons.evaluate().length} ADTextButton widgets'); + + final allText = find.byType(Text); + print('Found ${allText.evaluate().length} Text widgets'); + + // Try finding the button widget itself + if (allButtons.evaluate().isNotEmpty) { + await tester.tap(allButtons.first); + await tester.pumpAndSettle(); + } + + // The button tap should execute clearStoredCredentials + expect(find.byType(OAuth2Fields), findsOneWidget); + }); + + testWidgets('tests empty credentials file handling', + (WidgetTester tester) async { + mockAuthData = const AuthModel( + type: APIAuthType.oauth2, + oauth2: AuthOAuth2Model( + grantType: OAuth2GrantType.authorizationCode, + authorizationUrl: 'https://auth.apidash.dev/authorize', + accessTokenUrl: 'https://auth.apidash.dev/token', + clientId: 'client_id', + clientSecret: 'client_secret', + credentialsFilePath: '', // Empty credentials file path + ), + ); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + settingsProvider.overrideWith((ref) => ThemeStateNotifier( + settingsModel: const SettingsModel( + workspaceFolderPath: '/test/workspace', + ), + )), + selectedRequestModelProvider.overrideWith((ref) => null), + ], + child: Portal( + child: MaterialApp( + home: Scaffold( + body: OAuth2Fields( + authData: mockAuthData, + updateAuth: mockUpdateAuth, + ), + ), + ), + ), + ), + ); + + // Widget should render normally even with empty credentials file path + expect(find.byType(OAuth2Fields), findsOneWidget); + expect(find.byType(ExtendedTextField), findsAtLeastNWidgets(5)); + }); + + testWidgets('tests clear credentials with null or empty file path', + (WidgetTester tester) async { + mockAuthData = const AuthModel( + type: APIAuthType.oauth2, + oauth2: AuthOAuth2Model( + grantType: OAuth2GrantType.authorizationCode, + authorizationUrl: 'https://auth.apidash.dev/authorize', + accessTokenUrl: 'https://auth.apidash.dev/token', + clientId: 'client_id', + clientSecret: 'client_secret', + credentialsFilePath: null, // null credentials file path + refreshToken: 'test_refresh', + identityToken: 'test_identity', + accessToken: 'test_access', + ), + ); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + settingsProvider.overrideWith((ref) => ThemeStateNotifier( + settingsModel: const SettingsModel( + workspaceFolderPath: '/test/workspace', + ), + )), + selectedRequestModelProvider.overrideWith((ref) => null), + ], + child: Portal( + child: MaterialApp( + home: Scaffold( + body: OAuth2Fields( + authData: mockAuthData, + updateAuth: mockUpdateAuth, + ), + ), + ), + ), + ), + ); + + // Find and tap the clear button by button type + final clearButton = find.byType(ADTextButton); + if (clearButton.evaluate().isNotEmpty) { + await tester.tap(clearButton.first); + await tester.pumpAndSettle(); + } + + // Should handle null credentials file path gracefully + expect(find.byType(OAuth2Fields), findsOneWidget); + }); + + testWidgets('tests credential file loading with empty credentials', + (WidgetTester tester) async { + mockAuthData = const AuthModel( + type: APIAuthType.oauth2, + oauth2: AuthOAuth2Model( + grantType: OAuth2GrantType.authorizationCode, + authorizationUrl: 'https://auth.apidash.dev/authorize', + accessTokenUrl: 'https://auth.apidash.dev/token', + clientId: 'client_id', + clientSecret: 'client_secret', + credentialsFilePath: '/nonexistent/path/oauth2_credentials.json', + ), + ); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + settingsProvider.overrideWith((ref) => ThemeStateNotifier( + settingsModel: const SettingsModel( + workspaceFolderPath: '/test/workspace', + ), + )), + selectedRequestModelProvider.overrideWith((ref) => null), + ], + child: Portal( + child: MaterialApp( + home: Scaffold( + body: OAuth2Fields( + authData: mockAuthData, + updateAuth: mockUpdateAuth, + ), + ), + ), + ), + ), + ); + + // Test that widget handles missing credential files gracefully + expect(find.byType(OAuth2Fields), findsOneWidget); + expect(find.byType(ExtendedTextField), findsAtLeastNWidgets(5)); + }); + + testWidgets( + 'tests _getExpirationText with different token expiration states', + (WidgetTester tester) async { + // Test with no token expiration + mockAuthData = const AuthModel( + type: APIAuthType.oauth2, + oauth2: AuthOAuth2Model( + grantType: OAuth2GrantType.authorizationCode, + authorizationUrl: 'https://auth.apidash.dev/authorize', + accessTokenUrl: 'https://auth.apidash.dev/token', + clientId: 'client_id', + clientSecret: 'client_secret', + accessToken: 'test_token', + ), + ); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + settingsProvider.overrideWith((ref) => ThemeStateNotifier( + settingsModel: const SettingsModel( + workspaceFolderPath: '/test/workspace', + ), + )), + selectedRequestModelProvider.overrideWith((ref) => null), + ], + child: Portal( + child: MaterialApp( + home: Scaffold( + body: OAuth2Fields( + authData: mockAuthData, + updateAuth: mockUpdateAuth, + ), + ), + ), + ), + ), + ); + + // Widget should render without showing expiration text + expect(find.byType(OAuth2Fields), findsOneWidget); + }); + + testWidgets('tests code challenge method with plaintext selection', + (WidgetTester tester) async { + mockAuthData = const AuthModel( + type: APIAuthType.oauth2, + oauth2: AuthOAuth2Model( + grantType: OAuth2GrantType.authorizationCode, + authorizationUrl: 'https://auth.apidash.dev/authorize', + accessTokenUrl: 'https://auth.apidash.dev/token', + clientId: 'client_id', + clientSecret: 'client_secret', + codeChallengeMethod: 'plaintext', + ), + ); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + settingsProvider.overrideWith((ref) => ThemeStateNotifier( + settingsModel: const SettingsModel( + workspaceFolderPath: '/test/workspace', + ), + )), + selectedRequestModelProvider.overrideWith((ref) => null), + ], + child: Portal( + child: MaterialApp( + home: Scaffold( + body: OAuth2Fields( + authData: mockAuthData, + updateAuth: mockUpdateAuth, + ), + ), + ), + ), + ), + ); + + // Find the code challenge method dropdown + final stringPopupMenus = find.byType(ADPopupMenu); + if (stringPopupMenus.evaluate().isNotEmpty) { + // Tap the dropdown to open it + await tester.tap(stringPopupMenus.first); + await tester.pumpAndSettle(); + + // Find and tap the 'SHA-256' option to change from plaintext + final sha256Option = find.text('SHA-256'); + if (sha256Option.evaluate().isNotEmpty) { + await tester.tap(sha256Option.first); + await tester.pumpAndSettle(); + + // Verify that updateAuth was called with the new method + expect(capturedAuthUpdates.length, greaterThan(0)); + } + } + + expect(find.byType(OAuth2Fields), findsOneWidget); + }); + }); +} diff --git a/test/utils/pre_post_script_utils_test.dart b/test/utils/pre_post_script_utils_test.dart index ddd8369f6..174d6aa77 100644 --- a/test/utils/pre_post_script_utils_test.dart +++ b/test/utils/pre_post_script_utils_test.dart @@ -1257,10 +1257,8 @@ void main() { group('Pre-request Script - Request Modification Tests', () { test('should modify headers correctly', () async { - List? capturedValues; void mockUpdateEnv( EnvironmentModel envModel, List values) { - capturedValues = values; } final result = await handlePreRequestScript(