├── .github ├── CONTRIBUTING.md ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md └── workflows │ ├── code-style.yml │ └── tests.yml ├── .gitignore ├── LICENSE ├── README.md ├── composer.json ├── phpstan.neon ├── phpunit.xml ├── pint.json ├── src ├── AccessToken.php ├── Credentials.php ├── LaravelHttpOAuthHelperServiceProvider.php ├── Options.php ├── RefreshToken.php ├── TokenStore.php └── UrlHelper.php └── tests ├── Pest.php ├── TestCase.php └── Unit ├── ArchTest.php ├── CredentialsTest.php ├── MacroTest.php ├── OptionsTest.php ├── RefreshTokenTest.php ├── TokenStoreTest.php └── UrlHelperTest.php /.github/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contribution Guide 2 | 3 | This project welcomes contributions. 4 | 5 | New features should be covered by unit tests and linting. 6 | Including tests increases your chances of getting the PR accepted. 7 | I might merge even if tests are missing, but it will likely take more time. 8 | 9 | Run `composer check` before submitting a pull request and fix what you can there to ensure that the code is up to the standard. 10 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | 2 | github: [pelmered] 3 | ko_fi: pelmered 4 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **Steps To Reproduce** 14 | - Show the code of your field. 15 | - What changes have you made to your global config? 16 | - Version of: 17 | - This package 18 | - Filament 19 | - PHP 20 | - Laravel 21 | - Anything else that you think might be relevant 22 | 23 | **Expected behavior and actual behavior** 24 | A clear and concise description of what you expected to happen and what actually happens. 25 | 26 | **Screenshots** 27 | If applicable, add screenshots to help explain your problem. 28 | 29 | **Additional context** 30 | Add any other context about the problem here. 31 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/workflows/code-style.yml: -------------------------------------------------------------------------------- 1 | name: Code style 2 | 3 | on: 4 | - push 5 | - pull_request 6 | 7 | jobs: 8 | lint: 9 | runs-on: ubuntu-latest 10 | strategy: 11 | fail-fast: true 12 | matrix: 13 | php: [8.3] 14 | 15 | steps: 16 | - name: Checkout code 17 | uses: actions/checkout@v4 18 | 19 | - name: Run Laravel Pint 20 | uses: aglipanci/laravel-pint-action@latest 21 | with: 22 | verboseMode: true 23 | configPath: ./pint.json 24 | onlyDirty: true 25 | 26 | - name: Commit linted files 27 | uses: stefanzweifel/git-auto-commit-action@v5 28 | with: 29 | commit_message: "Fixes coding style" 30 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | 2 | name: Tests 3 | 4 | on: 5 | push: 6 | branches: [main] 7 | pull_request: 8 | branches: [main] 9 | 10 | jobs: 11 | test: 12 | runs-on: ${{ matrix.os }} 13 | strategy: 14 | matrix: 15 | os: [ubuntu-latest, macos-latest, windows-latest] 16 | php: [8.2, 8.3, 8.4] 17 | #dependencies: [lowest, highest] 18 | stability: [prefer-lowest, prefer-stable] 19 | #stability: [prefer-stable] 20 | 21 | name: P${{ matrix.php }} - ${{ matrix.stability }} - ${{ matrix.os }} 22 | 23 | steps: 24 | - name: Checkout code 25 | uses: actions/checkout@v4 26 | 27 | - name: Setup PHP 28 | uses: shivammathur/setup-php@v2 29 | with: 30 | php-version: ${{ matrix.php }} 31 | extensions: intl, fileinfo, zip 32 | - name: Install dependencies 33 | run: composer update --${{ matrix.stability }} --prefer-dist --no-interaction --ignore-platform-reqs 34 | - name: Execute tests 35 | run: vendor/bin/pest 36 | 37 | report-metrics: 38 | runs-on: ubuntu-latest 39 | 40 | steps: 41 | - name: Checkout code 42 | uses: actions/checkout@v4 43 | with: 44 | fetch-depth: 2 45 | 46 | - name: Setup PHP 47 | uses: shivammathur/setup-php@v2 48 | with: 49 | php-version: 8.3 50 | extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, bcmath, soap, intl, gd, exif, iconv, imagick, fileinfo 51 | coverage: pcov 52 | - name: Install dependencies 53 | run: composer update --prefer-stable --prefer-dist --no-interaction 54 | - name: Execute tests 55 | run: vendor/bin/pest --coverage-clover=build/logs/clover.xml 56 | - name: Execute type coverage tests 57 | run: vendor/bin/pest --type-coverage --type-coverage-json=build/logs/pest-coverage.json 58 | 59 | - name: Upload Test Coverage 60 | env: 61 | OTTERWISE_TOKEN: ${{ secrets.OTTERWISE_TOKEN }} 62 | run: bash <(curl -s https://raw.githubusercontent.com/getOtterWise/bash-uploader/main/uploader.sh) 63 | 64 | - name: Upload Types Coverage 65 | env: 66 | OTTERWISE_TOKEN: ${{ secrets.OTTERWISE_TOKEN }} 67 | run: bash <(curl -s https://raw.githubusercontent.com/getOtterWise/bash-uploader/main/uploader.sh) --type-coverage-file build/logs/pest-coverage.json 68 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | /vendor 3 | .phpunit.cache/ 4 | coverage.xml 5 | composer.lock 6 | /reports 7 | 8 | /tests/cache 9 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Peter Elmered 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Laravel HTTP Client Auth helper 🤝 2 | 3 | An easy-to-use helper for Laravel HTTP Client to make manage API requests with a two-step auth flow. 4 | For example, OAuth2 or refresh tokens to get a new short-lived access token.\ 5 | This helper takes care of all the headaches and boilerplate code with a simple and easy-to-use API. 6 | 7 | #### Features: 8 | 9 | - Automatically fetches new access tokens when needed. 10 | - Automatically caches the access tokens for their lifetime. 11 | - Extends the Laravel HTTP Client to make it straightforward to use. Se the [usage section](#usage) below for examples. 12 | - Supports common auth flows like OAuth2 and refresh tokens with most grant types. 13 | - No configuration and no boilerplate code required. Just [require](#installation) and use with a simple and consise API. 14 | - Supports [callbacks](#customize-with-callbacks) to customize the behaviour when needed. 15 | 16 | #### Vision, roadmap & plans for the future 17 | 18 | I want to support as many common auth flows as possible.\ 19 | If you have a use case that is not super obscure, 20 | please [open an issue](https://github.com/pelmered/laravel-http-client-auth-helper/issues/new) where you provide as much detail as possible, 21 | or [submit a PR](https://github.com/pelmered/laravel-http-client-auth-helper/pulls) with a working solution. 22 | 23 | ##### Plans for 1.1 - [Milestone](https://github.com/pelmered/laravel-http-client-auth-helper/milestone/2) 24 | 25 | - Helper for automatically refresh tokens in the background before they expire. 26 | - Add support for authorization_code and implicit OAuth2 grants. 27 | - Tokens owned by a model. For example, tokens associated with a user model rather than the whole application. 28 | 29 | [![Latest Stable Version](https://poser.pugx.org/pelmered/laravel-http-client-auth-helper/v/stable)](https://packagist.org/packages/pelmered/laravel-http-client-auth-helper) 30 | [![Total Downloads](https://poser.pugx.org/pelmered/laravel-http-client-auth-helper/d/total)](//packagist.org/packages/pelmered/laravel-http-client-auth-helper) 31 | [![Monthly Downloads](https://poser.pugx.org/pelmered/laravel-http-client-auth-helper/d/monthly)](//packagist.org/packages/pelmered/laravel-http-client-auth-helper) 32 | [![License](https://poser.pugx.org/pelmered/laravel-http-client-auth-helper/license)](https://packagist.org/packages/pelmered/laravel-http-client-auth-helper) 33 | 34 | [![Tests](https://github.com/pelmered/laravel-http-client-auth-helper/actions/workflows/tests.yml/badge.svg?branch=main)](https://github.com/pelmered/laravel-http-client-auth-helper/actions/workflows/tests.yml) 35 | [![Build Status](https://scrutinizer-ci.com/g/pelmered/laravel-http-client-auth-helper/badges/build.png?b=main)](https://scrutinizer-ci.com/g/pelmered/laravel-http-client-auth-helper/build-status/main) 36 | [![Scrutinizer Code Quality](https://scrutinizer-ci.com/g/pelmered/laravel-http-client-auth-helper/badges/quality-score.png?b=main)](https://scrutinizer-ci.com/g/pelmered/laravel-http-client-auth-helper/?branch=master) 37 | [![Code Coverage](https://scrutinizer-ci.com/g/pelmered/laravel-http-client-auth-helper/badges/coverage.png?b=main)](https://scrutinizer-ci.com/g/pelmered/laravel-http-client-auth-helper/?branch=main) 38 | 39 | [![Tested on PHP 8.2 to 8.4](https://img.shields.io/badge/Tested%20on%20PHP-8.2%20|%208.3%20|%208.4-brightgreen.svg?maxAge=2419200)](https://github.com/pelmered/filament-money-field/actions/workflows/tests.yml) 40 | [![Tested on OS:es Linux, MacOS, Windows](https://img.shields.io/badge/Tested%20on%20lastest%20versions%20of-%20Ubuntu%20|%20MacOS%20|%20Windows-brightgreen.svg?maxAge=2419200)](https://github.com/pelmered/laravel-http-client-auth-helper/actions/workflows/tests.yml) 41 | 42 | ## Table of contents 43 | 44 | 45 | - [Requirements](#requirements) 46 | - [Vision, roadmap & plans for the future](#vision-roadmap--plans-for-the-future) 47 | - [Contributing](#contributing) 48 | * [Issues & Bug Reports](#issues--bug-reports) 49 | - [Installation](#installation) 50 | - [Options reference](#options-reference) 51 | + [scopes - `array`](#scopes---array) 52 | + [authType - `string`](#authtype---string) 53 | + [grantType - `string`](#granttype---string) 54 | + [tokenType - `string`](#tokentype---string) 55 | + [tokenName - `string`](#tokenname---string) 56 | + [expires - `int|string|Closure|Carbon`](#expires---intstringclosurecarbon) 57 | + [accessToken - `string|Closure`](#accesstoken---stringclosure) 58 | + [tokenTypeCustomCallback - `?Closure`](#tokentypecustomcallback---closure) 59 | + [cacheKey - `?string`](#cachekey---string) 60 | + [cacheDriver - `?string`](#cachedriver---string) 61 | - [Usage](#usage) 62 | + [Minimal example:](#minimal-example) 63 | + [All parameters with default values:](#all-parameters-with-default-values) 64 | + [For full type safety, you can also provide objects instead of arrays:](#for-full-type-safety-you-can-also-provide-objects-instead-of-arrays) 65 | + [Customize with callbacks](#customize-with-callbacks) 66 | * [Integration tips](#integration-tips) 67 | 68 | 69 | 70 | ## Requirements 71 | 72 | - PHP 8.2 or higher 73 | - Laravel 10 74 | 75 | ## Vision, roadmap & plans for the future 76 | 77 | I want to support as many common auth flows as possible.\ 78 | If you have a use case that is not super obscure, 79 | please [open an issue](https://github.com/pelmered/laravel-http-client-auth-helper/issues/new) where you provide as much detail as possible, 80 | or [submit a PR](https://github.com/pelmered/laravel-http-client-auth-helper/pulls). 81 | 82 | ## Contributing 83 | 84 | See [Contribution Guide](.github/CONTRIBUTING.md) before sending pull requests. 85 | 86 | ### Issues & Bug Reports 87 | 88 | When you are submitting issues, I appreciate if you could provide a failing test case. That makes my job a lot easier.\ 89 | I will try to fix reported issues as soon as possible, but I do this in my spare time, so I might not be able to do it immediately. 90 | 91 | ## Installation 92 | 93 | ```bash 94 | composer require pelmered/laravel-http-client-auth-helper 95 | ``` 96 | 97 | ## Usage 98 | 99 | It's really simple to use. Just add the `withRefreshToken` method to your HTTP request and provide the necessary parameters. No configuration needed. 100 | 101 | #### Minimal example: 102 | ```php 103 | $response = Http::withRefreshToken( 104 | 'https://example.com/token.oauth2', 105 | [ 106 | 'client_id', 107 | 'client_secret', 108 | ] 109 | )->get( 110 | 'https://example.com/api', 111 | ); 112 | ``` 113 | 114 | #### All parameters with default values: 115 | ```php 116 | $response = Http::withRefreshToken( 117 | 'https://example.com/token.oauth2', 118 | [ 119 | 'client_id', 120 | 'client_secret', 121 | ], 122 | [ 123 | // Options, see the end of the readme for full explaination of each field. 124 | 'scopes' => [], 125 | 'expires' => 'expires_in', // When token should be considered expired. A string key in the response JSON for the expiration. We try to parse different formats and then remove 1 minute to be on the safe side. 126 | 'auth_type' => 'body', // 'body' or 'header' 127 | 'access_token' => 'access_token', // Key for the access token in the response JSON 128 | ], 129 | 'Bearer' 130 | )->get( 131 | 'https://example.com/api', 132 | ); 133 | ``` 134 | 135 | #### For full type safety, you can also provide objects instead of arrays: 136 | 137 | ```php 138 | use Pelmered\LaravelHttpOAuthHelper\AccessToken; 139 | use Pelmered\LaravelHttpOAuthHelper\Credentials; 140 | use Pelmered\LaravelHttpOAuthHelper\Options; 141 | use Pelmered\LaravelHttpOAuthHelper\RefreshToken; 142 | 143 | $response = Http::withRefreshToken( 144 | 'https://example.com/token.oauth2', 145 | new Credentials( 146 | clientId: 'client_id', 147 | clientSecret: 'client_secret', 148 | ), 149 | // Options, see the end of the readme for full explaination of each field. 150 | new Options( 151 | scopes: ['scope1', 'scope2'], 152 | expires: 3600, 153 | grantType: 'password_credentials', 154 | authType: Credentials::AUTH_TYPE_BODY, 155 | tokenType: AccessToken::TOKEN_TYPE_BEARER, 156 | ), 157 | )->get( 158 | 'https://example.com/api', 159 | ); 160 | ``` 161 | 162 | #### Customize with callbacks 163 | You can also provide callbacks for `expires`, `auth_type`, and `access_token` to customize the behavior. 164 | ```php 165 | $response = Http::withRefreshToken( 166 | 'https://example.com/token.oauth2', 167 | [ 168 | 'client_id', 169 | 'client_secret', 170 | ], 171 | [ 172 | 'expires' => fn($response) => $response->json()['expires_in'] - 300, // Should return the ttl in seconds that has been parsed from the response and can be manipulated as you want. 173 | 'access_token' => fn($response) => $response->access_token, // Should return the access token that has been parsed from the response. 174 | ], 175 | 'Bearer' 176 | )->get( 177 | 'https://example.com/api', 178 | ); 179 | ``` 180 | 181 | Custom auth for refreshing token: 182 | 183 | ```php 184 | use Illuminate\Http\Client\PendingRequest; 185 | 186 | $response = Http::withRefreshToken( 187 | 'https://example.com/token.oauth2', 188 | [ 189 | 'client_id', 190 | 'client_secret', 191 | ], 192 | [ 193 | 'expires' => fn($response) => $response->json()['expires_in'] - 300, // Should return the ttl in seconds that has been parsed from the response and can be manipulated as you want. 194 | 'access_token' => fn($response) => $response->access_token, // Should return the access token that has been parsed from the response. 195 | 'auth_type' => 'custom', 196 | 'apply_auth_token' => fn(PendingRequest $httpClient) => $request->withHeader('Authorization', 'Bearer ' . $token), 197 | )->get( 198 | 'https://example.com/api', 199 | ); 200 | ``` 201 | 202 | For more examples, check out the [Macro tests](tests/Unit/MacroTest.php). 203 | 204 | ### Integration tips 205 | 206 | If you use the same token in multiple places, you can create the client only once and save it. For example: 207 | ```php 208 | $this->client = Http::withRefreshToken( 209 | 'https://example.com/token.oauth2', 210 | [ 211 | 'client_id', 212 | 'client_secret', 213 | ], 214 | [ 215 | 'scopes' => ['read:posts', 'write:posts', 'read:comments'], 216 | ] 217 | )->baseUrl('https://example.com/api'); 218 | ``` 219 | to use it later like this: 220 | ```php 221 | $this->client->get('posts'); 222 | 223 | $this->client->get('comments'); 224 | 225 | $this->client->post('posts', [ 226 | 'title' => 'My post', 227 | 'content' => 'My content', 228 | ]); 229 | ``` 230 | 231 | You can also resolve it in the container if you want. 232 | In your service provider: 233 | ```php 234 | $this->app->singleton('my-oauth-client', function ($app) { 235 | return Http::withRefreshToken( 236 | 'https://example.com/token.oauth2', 237 | [ 238 | 'client_id', 239 | 'client_secret', 240 | ], 241 | [ 242 | 'scopes' => ['read:posts', 'write:posts', 'read:comments'], 243 | ] 244 | )->baseUrl('https://example.com/api'); 245 | }); 246 | ``` 247 | and then use it anywhere like this: 248 | ```php 249 | app('my-oauth-client')->get('posts'); 250 | ``` 251 | 252 | ## Options reference (Third parameter in `withRefreshToken()` 253 | 254 | ### scopes - `array` 255 | Scopes to send when requesting an access token. 256 | Typically only used for OAuth2 flows.\ 257 | **Possible options:** array with strings 258 | **Default:** `[]` 259 | 260 | ### authType - `string` 261 | The type of authorization for the refresh token request.\ 262 | **Possible options:** `Credentials::AUTH_TYPE_BEARER`, `Credentials::AUTH_TYPE_BODY`, `Credentials::AUTH_TYPE_QUERY`, `Credentials::AUTH_TYPE_BASIC`, `Credentials::AUTH_TYPE_CUSTOM`,\ 263 | **Default:** `Credentials::AUTH_TYPE_BEARER` (=`'Bearer'`) 264 | 265 | ### grantType - `string` 266 | Grant type for OAuth2 flows.\ 267 | **Possible options:** `Credentials::GRANT_TYPE_CLIENT_CREDENTIALS`, `Credentials::GRANT_TYPE_PASSWORD_CREDENTIALS` (authorization_code and implicit grants are not yet supported. See [issue #3](https://github.com/pelmered/laravel-http-client-auth-helper/issues/3))\ 268 | **Default:** `Credentials::GRANT_TYPE_CLIENT_CREDENTIALS` (=`'client_credentials'`) 269 | 270 | ### tokenType - `string` 271 | How the access token should be applied to all subsequent requests.\ 272 | **Possible options:** `AccessToken::TOKEN_TYPE_BEARER`, `AccessToken::TOKEN_TYPE_QUERY`, `AccessToken::TOKEN_TYPE_CUSTOM` \ 273 | **Default:** `AccessToken::TOKEN_TYPE_BEARER` (=`'Bearer'`) 274 | 275 | ### tokenName - `string` 276 | The name of the token field. This only applies for when the token is applied as a query parameter or to the body of the request.\ 277 | **Possible options:** Any string\ 278 | **Default:** `'token'` 279 | 280 | ### expires - `int|string|Closure|Carbon` 281 | This determines when the access token expires.\ 282 | **Possible options:** \ 283 | **integer** - for how long until expiry in seconds)\ 284 | **string** - Can be key of the field in response that contains the expiry of the token. Can also be a string with a date. This is then parsed by Carbon::parse so any format that Carbon can parse is acceptable.\ 285 | **Closure** - A closure that receives the refresh response and can return any other acceptable value (integer, string or Carbon object).\ 286 | **Carbon** - A Carbon object with the time of the expiry.\ 287 | **Default:** `3600` 288 | 289 | ### accessToken - `string|Closure` 290 | This is where the access token can be found on the refresh response.\ 291 | **Possible options:**\ 292 | **string** - The key of the access token in the refresh response.\ 293 | **Closure** - A closure that receives the refresh response and should return the token as a string.\ 294 | **Default:** `'access_token'` 295 | 296 | ### tokenTypeCustomCallback - `?Closure` 297 | A callback for giving dull control of how the authentication should be applied. 298 | The closure receives the Http client and should return a new Http Client where the auth information has been appended.\ 299 | **Possible options:**\ Any closure that returns a Http Client (`Illuminate\Http\Client\PendingRequest`).\ 300 | **Default:** `null` 301 | 302 | ### cacheKey - `?string` 303 | The cache key that should be used to save the access tokens. 304 | If left empty, it will be generated based on the refresh URL.\ 305 | **Possible options:**\ 306 | **Default:** `null` 307 | 308 | ### cacheDriver - `?string` 309 | The cache driver/store that should be used for storing the access tokens. 310 | If left empty, the Laravel default will be used.\ 311 | **Possible options:**\ 312 | **Default:** `null` 313 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "pelmered/laravel-http-client-auth-helper", 3 | "type": "library", 4 | "license": "MIT", 5 | "autoload": { 6 | "psr-4": { 7 | "Pelmered\\LaravelHttpOAuthHelper\\": "src/" 8 | } 9 | }, 10 | "autoload-dev": { 11 | "psr-4": { 12 | "Pelmered\\LaravelHttpOAuthHelper\\Tests\\": "tests/", 13 | "Workbench\\App\\": "workbench/app/", 14 | "Workbench\\Database\\Factories\\": "workbench/database/factories/", 15 | "Workbench\\Database\\Seeders\\": "workbench/database/seeders/" 16 | } 17 | }, 18 | "authors": [ 19 | { 20 | "name": "Peter Elmered", 21 | "email": "peter@elmered.com" 22 | } 23 | ], 24 | "minimum-stability": "dev", 25 | "prefer-stable": true, 26 | "require": { 27 | "php": "^8.2", 28 | "illuminate/support": "^10 || ^11 || ^12", 29 | "guzzlehttp/guzzle": "^7.0" 30 | }, 31 | "require-dev": { 32 | "orchestra/testbench": "^9.0|^10.0", 33 | "larastan/larastan": "^2.0 | ^3.0", 34 | "laravel/pint": "^1.0", 35 | "pestphp/pest": "^3.0", 36 | "pestphp/pest-plugin-mutate": "^3.0", 37 | "pestphp/pest-plugin-type-coverage": "^3.0", 38 | "spatie/laravel-ray": "^1.37.1", 39 | "phpmd/phpmd": "^2.15" 40 | }, 41 | "extra": { 42 | "laravel": { 43 | "providers": [ 44 | "Pelmered\\LaravelHttpOAuthHelper\\LaravelHttpOAuthHelperServiceProvider" 45 | ] 46 | } 47 | }, 48 | "scripts": { 49 | "phpstan": "vendor/bin/phpstan analyse src --level=8", 50 | "pint": "vendor/bin/pint", 51 | "test": [ 52 | "@php vendor/bin/testbench package:test" 53 | ], 54 | "lint": [ 55 | "composer phpstan", 56 | "composer pint", 57 | "composer type" 58 | ], 59 | "check": [ 60 | "composer pint", 61 | "composer phpstan", 62 | "composer test --min=100" 63 | ], 64 | "coverage": [ 65 | "@php vendor/bin/phpunit --testsuite=default --coverage-clover=coverage.xml --coverage-filter=src --path-coverage" 66 | ], 67 | "type": [ 68 | "@php vendor/bin/pest --type-coverage" 69 | ], 70 | "update-readme-toc": "markdown-toc -i README.md" 71 | }, 72 | "config": { 73 | "allow-plugins": { 74 | "pestphp/pest-plugin": true 75 | } 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /phpstan.neon: -------------------------------------------------------------------------------- 1 | parameters: 2 | level: 8 3 | paths: 4 | - src 5 | - tests 6 | treatPhpDocTypesAsCertain: false 7 | -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 12 | 13 | 14 | tests 15 | 16 | 17 | 18 | 19 | ./src 20 | 21 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /pint.json: -------------------------------------------------------------------------------- 1 | { 2 | "preset": "laravel", 3 | "rules": { 4 | "binary_operator_spaces": { 5 | "default": "align_single_space_minimal", 6 | "operators": { 7 | "=>": "align_single_space_minimal" 8 | } 9 | } 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/AccessToken.php: -------------------------------------------------------------------------------- 1 | accessToken; 34 | } 35 | 36 | public function getExpiresAt(): Carbon 37 | { 38 | return $this->expiresAt; 39 | } 40 | 41 | public function getExpiresIn(): int 42 | { 43 | return (int) round(Carbon::now()->diffInSeconds($this->expiresAt)); 44 | } 45 | 46 | public function getTokenType(): string 47 | { 48 | return $this->tokenType; 49 | } 50 | 51 | public function getTokenName(): string 52 | { 53 | return $this->tokenName; 54 | } 55 | 56 | public function getCustomCallback(): ?Closure 57 | { 58 | return $this->customCallback; 59 | } 60 | 61 | public function getHttpClient(PendingRequest $httpClient): PendingRequest 62 | { 63 | return match ($this->tokenType) { 64 | self::TOKEN_TYPE_BEARER => $httpClient->withToken($this->accessToken), 65 | self::TOKEN_TYPE_QUERY => $httpClient->withQueryParameters([$this->tokenName => $this->accessToken]), 66 | self::TOKEN_TYPE_CUSTOM => $this->resolveCustomAuth($httpClient), 67 | default => throw new InvalidArgumentException('Invalid auth type') 68 | }; 69 | } 70 | 71 | protected function resolveCustomAuth(PendingRequest $httpClient): PendingRequest 72 | { 73 | if (! is_callable($this->customCallback)) { 74 | throw new InvalidArgumentException('customCallback must be callable'); 75 | } 76 | 77 | return ($this->customCallback)($httpClient); 78 | } 79 | 80 | } 81 | -------------------------------------------------------------------------------- /src/Credentials.php: -------------------------------------------------------------------------------- 1 | $credentials 36 | */ 37 | public function __construct( 38 | string|array|callable $credentials = [], 39 | protected ?string $token = null, 40 | protected ?string $clientId = null, 41 | protected ?string $clientSecret = null, 42 | ) { 43 | if (! empty($credentials)) { 44 | $this->parseCredentialsArray($credentials); 45 | } 46 | 47 | $this->validate(); 48 | } 49 | 50 | /** 51 | * @return array 52 | */ 53 | public function toArray(): array 54 | { 55 | return get_object_vars($this); 56 | } 57 | 58 | protected function validate(): void 59 | { 60 | Validator::make($this->toArray(), [ 61 | 'token' => 'required_without_all:clientId,clientSecret,customCallback|string|nullable', 62 | 'clientId' => 'required_with:clientSecret|string|nullable', 63 | 'clientSecret' => 'required_with:clientId|string|nullable', 64 | 'customCallback' => 'required_without_all:token,clientId,clientSecret|nullable', 65 | ])->validate(); 66 | } 67 | 68 | public function setOptions(Options $options): self 69 | { 70 | $this->options = $options; 71 | 72 | return $this; 73 | } 74 | 75 | /** 76 | * @param string|array|callable $credentials 77 | */ 78 | public function parseCredentialsArray(string|array|callable $credentials): void 79 | { 80 | if (is_string($credentials)) { 81 | $this->setRefreshToken($credentials); 82 | 83 | return; 84 | } 85 | 86 | if (is_callable($credentials)) { 87 | $this->customCallback = $credentials(...); 88 | 89 | return; 90 | } 91 | 92 | $credentials = array_filter($credentials); 93 | $arrayLength = count($credentials); 94 | 95 | if ($arrayLength > 0 && array_is_list($credentials)) { 96 | match ($arrayLength) { 97 | 1 => $this->setRefreshToken($credentials[0]), 98 | 2 => $this->setClientCredentialsPair($credentials[0], $credentials[1]), 99 | default => throw new InvalidArgumentException('Invalid credentials. Check documentation/readme.'), 100 | }; 101 | 102 | return; 103 | } 104 | } 105 | 106 | public function addAuthToRequest(PendingRequest $httpClient, Options $options): PendingRequest 107 | { 108 | if ($options->authType === self::AUTH_TYPE_BODY) { 109 | return $httpClient; 110 | } 111 | if (is_callable($this->customCallback)) { 112 | return ($this->customCallback)($httpClient); 113 | } 114 | 115 | if ($options->authType === self::AUTH_TYPE_BASIC) { 116 | if (! $this->clientId || ! $this->clientSecret) { 117 | throw new InvalidArgumentException('Basic auth requires client id and client secret. Check documentation/readme.'); 118 | } 119 | 120 | return $httpClient->withBasicAuth($this->clientId, $this->clientSecret); 121 | } 122 | 123 | if ($this->token) { 124 | if ($options->authType === self::AUTH_TYPE_QUERY) { 125 | return $httpClient->withQueryParameters([ 126 | $options->tokenName => $this->token, 127 | ]); 128 | } 129 | 130 | return $httpClient->withToken($this->token, $options->authType); 131 | } 132 | 133 | 134 | return $httpClient; 135 | } 136 | 137 | /** 138 | * @param array $requestBody 139 | * @return array 140 | */ 141 | public function addAuthToBody(array $requestBody, Options $options): array 142 | { 143 | if ($options->authType !== self::AUTH_TYPE_BODY) { 144 | return $requestBody; 145 | } 146 | if ($this->clientId && $this->clientSecret) { 147 | return $requestBody + ['client_id' => $this->clientId, 'client_secret' => $this->clientSecret]; 148 | } 149 | if ($this->token) { 150 | return $requestBody + [$options->tokenName => $this->token]; 151 | } 152 | 153 | throw new InvalidArgumentException('Invalid credentials. Check documentation/readme.'); 154 | } 155 | 156 | public function setRefreshToken(string $token): void 157 | { 158 | $this->token = $token; 159 | } 160 | 161 | public function setClientCredentialsPair(string $clientId, string $clientSecret): void 162 | { 163 | $this->clientId = $clientId; 164 | $this->clientSecret = $clientSecret; 165 | } 166 | } 167 | -------------------------------------------------------------------------------- /src/LaravelHttpOAuthHelperServiceProvider.php: -------------------------------------------------------------------------------- 1 | '', 18 | 'client_id' => '', 19 | 'client_secret' => '', 20 | ], 21 | array|Options $options = [], 22 | ): PendingRequest { 23 | 24 | $options = $options instanceof Options ? $options : Options::make($options); 25 | $credentials = $credentials instanceof Credentials ? $credentials : new Credentials($credentials); 26 | 27 | $accessToken = TokenStore::get( 28 | refreshUrl: $refreshUrl, 29 | credentials: $credentials->setOptions($options), 30 | options: $options, 31 | ); 32 | 33 | /** @var PendingRequest|Factory $httpClient */ 34 | $httpClient = $this; 35 | 36 | // If we get a factory, we can create a new pending request 37 | if ($httpClient instanceof Factory) { 38 | $httpClient = $httpClient->createPendingRequest(); 39 | } 40 | 41 | return $accessToken->getHttpClient($httpClient); 42 | }); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/Options.php: -------------------------------------------------------------------------------- 1 | $scopes 14 | */ 15 | final public function __construct( 16 | public array $scopes = [], 17 | public string $authType = Credentials::AUTH_TYPE_BEARER, 18 | public string $grantType = Credentials::GRANT_TYPE_CLIENT_CREDENTIALS, 19 | public string $tokenType = AccessToken::TOKEN_TYPE_BEARER, 20 | public string $tokenName = 'token', 21 | public int|string|Closure|Carbon $expires = 3600, 22 | public string|Closure $accessToken = 'access_token', 23 | public ?Closure $tokenTypeCustomCallback = null, 24 | public ?string $cacheKey = null, 25 | public ?string $cacheDriver = null, 26 | ) { 27 | $this->validateOptions(); 28 | } 29 | 30 | /** 31 | * @return array 32 | */ 33 | public function toArray(): array 34 | { 35 | return get_object_vars($this); 36 | } 37 | 38 | protected function validateOptions(): void 39 | { 40 | // Note: closures can't be checked at this point since we don't have access to the response objects 41 | Validator::make((array) $this, [ 42 | 'scopes.*' => 'string', 43 | 'authType' => Rule::in([ 44 | Credentials::AUTH_TYPE_BEARER, 45 | Credentials::AUTH_TYPE_BODY, 46 | Credentials::AUTH_TYPE_QUERY, 47 | Credentials::AUTH_TYPE_BASIC, 48 | Credentials::AUTH_TYPE_CUSTOM, 49 | ]), 50 | 'grantType' => Rule::in([ 51 | Credentials::GRANT_TYPE_CLIENT_CREDENTIALS, 52 | Credentials::GRANT_TYPE_PASSWORD_CREDENTIALS, 53 | ]), 54 | 'tokenType' => Rule::in([ 55 | AccessToken::TOKEN_TYPE_BEARER, 56 | AccessToken::TOKEN_TYPE_QUERY, 57 | AccessToken::TOKEN_TYPE_CUSTOM, 58 | ]), 59 | 'tokenName' => 'string', 60 | ])->validate(); 61 | } 62 | 63 | public function getScopes(): string 64 | { 65 | return implode(' ', $this->scopes); 66 | } 67 | 68 | /** 69 | * @param array ...$parameters 70 | */ 71 | public static function make(...$parameters): static 72 | { 73 | $defaults = static::getDefaults(); 74 | $options = array_merge($defaults, ...$parameters); 75 | 76 | return new static(...$options); 77 | } 78 | 79 | /** 80 | * @return array 81 | */ 82 | protected static function getDefaults(): array 83 | { 84 | return [ 85 | 'scopes' => [], 86 | 'grantType' => Credentials::GRANT_TYPE_CLIENT_CREDENTIALS, 87 | 'tokenType' => AccessToken::TOKEN_TYPE_BEARER, 88 | 'authType' => Credentials::AUTH_TYPE_BEARER, 89 | 'expires' => 3600, 90 | 'accessToken' => 'access_token', 91 | ]; 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /src/RefreshToken.php: -------------------------------------------------------------------------------- 1 | 18 | */ 19 | protected array $requestBody = []; 20 | 21 | /** 22 | * @throws Exception 23 | */ 24 | public function __invoke( 25 | string $refreshUrl, 26 | Credentials $credentials, 27 | Options $options, 28 | ): AccessToken { 29 | $this->httpClient = Http::asForm(); 30 | 31 | $this->requestBody = [ 32 | 'grant_type' => $options->grantType, 33 | 'scope' => $options->getScopes(), 34 | ]; 35 | 36 | $this->resolveRefreshAuth($credentials, $options); 37 | 38 | $response = $this->httpClient->post($refreshUrl, $this->requestBody); 39 | 40 | return new AccessToken( 41 | accessToken: $this->getAccessTokenFromResponse($response, $options->accessToken), 42 | expiresAt: $this->getExpiresAtFromResponse($response, $options->expires), 43 | //tokenType: $options['auth_type'], 44 | tokenType: $options->tokenType, 45 | customCallback: $options->tokenTypeCustomCallback, 46 | tokenName: $options->tokenName, 47 | ); 48 | } 49 | 50 | protected function resolveRefreshAuth(Credentials $credentials, Options $options): void 51 | { 52 | $this->httpClient = $credentials->addAuthToRequest($this->httpClient, $options); 53 | $this->requestBody = $credentials->addAuthToBody($this->requestBody, $options); 54 | } 55 | 56 | protected function getAccessTokenFromResponse(Response $response, callable|string $accessTokenOption): string 57 | { 58 | return is_callable($accessTokenOption) ? $accessTokenOption($response) : $response->json()[$accessTokenOption]; 59 | } 60 | 61 | protected function getExpiresAtFromResponse(Response $response, callable|string|int|Carbon $expiresOption): Carbon 62 | { 63 | $expires = is_callable($expiresOption) ? $expiresOption($response) : $expiresOption; 64 | 65 | if (is_string($expires)) { 66 | if (isset($response->json()[$expires])) { 67 | $expires = $response->json()[$expires]; 68 | } 69 | 70 | if (is_int($expires)) { 71 | return Carbon::now()->addSeconds($expires - 60); 72 | } 73 | 74 | return Carbon::parse($expires)->subMinute(); 75 | } 76 | 77 | if (is_int($expires)) { 78 | return Carbon::now()->addSeconds($expires)->subMinute(); 79 | } 80 | 81 | if ($expires instanceof Carbon) { 82 | return $expires->subMinute(); 83 | } 84 | 85 | throw new InvalidArgumentException('Invalid expires option'); 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /src/TokenStore.php: -------------------------------------------------------------------------------- 1 | replace(['https://', '/'], ['', '-'])->__toString(); 14 | } 15 | 16 | /** 17 | * @throws Exception|\Psr\SimpleCache\InvalidArgumentException 18 | */ 19 | public static function get( 20 | string $refreshUrl, 21 | Credentials $credentials, 22 | Options $options, 23 | ): AccessToken { 24 | $cacheKey = $options->cacheKey ?? static::generateCacheKey($refreshUrl); 25 | 26 | $accessToken = Cache::store($options->cacheDriver)->get($cacheKey); 27 | 28 | if ($accessToken) { 29 | return $accessToken; 30 | } 31 | 32 | $accessToken = app(RefreshToken::class)(...func_get_args()); 33 | $ttl = $accessToken->getExpiresIn(); 34 | 35 | Cache::store($options->cacheDriver)->put($cacheKey, $accessToken, $ttl); 36 | 37 | return $accessToken; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/UrlHelper.php: -------------------------------------------------------------------------------- 1 | effectiveUri(); 11 | 12 | if (! $uri) { 13 | return null; 14 | } 15 | 16 | return self::parseTokenFromQueryString($response->effectiveUri()?->getQuery(), $queryKey); 17 | } 18 | 19 | public static function parseQueryTokenFromUrl(string $url, string $queryKey = 'token'): ?string 20 | { 21 | $queryString = parse_url($url, PHP_URL_QUERY); 22 | 23 | if (! $queryString) { 24 | return null; 25 | } 26 | 27 | return self::parseTokenFromQueryString($queryString, $queryKey); 28 | } 29 | 30 | public static function parseTokenFromQueryString(string $queryString, string $queryKey = 'token'): null|string|array 31 | { 32 | parse_str($queryString, $output); 33 | 34 | if (! isset($output[$queryKey])) { 35 | return null; 36 | } 37 | 38 | return $output[$queryKey] ?? null; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /tests/Pest.php: -------------------------------------------------------------------------------- 1 | in('Feature'); 15 | 16 | /* 17 | |-------------------------------------------------------------------------- 18 | | Expectations 19 | |-------------------------------------------------------------------------- 20 | | 21 | | When you're writing tests, you often need to check that values meet certain conditions. The 22 | | "expect()" function gives you access to a set of "expectations" methods that you can use 23 | | to assert different things. Of course, you may extend the Expectation API at any time. 24 | | 25 | */ 26 | 27 | use Carbon\Carbon; 28 | use Pelmered\LaravelHttpOAuthHelper\AccessToken; 29 | 30 | expect()->extend('toBeOne', function () { 31 | return $this->toBe(1); 32 | }); 33 | 34 | expect()->extend('toBeWithin', function ($integer, $acceptableDiff) { 35 | return $this->toBeBetween($integer-$acceptableDiff, $integer+$acceptableDiff); 36 | }); 37 | 38 | /* 39 | |-------------------------------------------------------------------------- 40 | | Functions 41 | |-------------------------------------------------------------------------- 42 | | 43 | | While Pest is very powerful out-of-the-box, you may have some testing code specific to your 44 | | project that you don't want to repeat in every file. Here you can also expose helpers as 45 | | global functions to help you to reduce the number of lines of code in your test files. 46 | | 47 | */ 48 | 49 | function something() 50 | { 51 | 52 | } 53 | function isSameAccessToken($accessToken1, $accessToken2, int $tolerableExpiryDiff = 0) 54 | { 55 | expect($accessToken1)->toBeInstanceOf(AccessToken::class) 56 | ->and($accessToken2)->toBeInstanceOf(AccessToken::class) 57 | ->and($accessToken1->getAccessToken())->toBe($accessToken2->getAccessToken()) 58 | ->and($accessToken1->getTokenType())->toBe($accessToken2->getTokenType()) 59 | ->and($accessToken1->getExpiresAt())->toBeInstanceOf(Carbon::class) 60 | ->and($accessToken1->getCustomCallback())->toBe($accessToken1->getCustomCallback()); 61 | 62 | $tolerableExpiryDiff >= 0 63 | ? expect($accessToken1->getExpiresIn())->toBe($accessToken1->getExpiresIn()) 64 | ->and($accessToken1->getExpiresAt()->getPreciseTimestamp(6))->toBe($accessToken2->getExpiresAt()->getPreciseTimestamp(6)) 65 | : expect($accessToken1->getExpiresIn())->toBeWithin($accessToken1->getExpiresIn(), $tolerableExpiryDiff) 66 | ->and($accessToken1->getExpiresAt()->timestamp)->toBeWithin($accessToken2->getExpiresAt()->timestamp, $tolerableExpiryDiff); 67 | } 68 | -------------------------------------------------------------------------------- /tests/TestCase.php: -------------------------------------------------------------------------------- 1 | url() === 'https://example.com/oauth/token') { 24 | if ($request->token = 'my_refresh_token') { 25 | return Http::response([ 26 | 'token_type' => 'Bearer', 27 | 'access_token' => 'this_is_my_access_token_from_body_refresh_token', 28 | 'scope' => implode(' ', $request->data()['scopes'] ?? []), 29 | 'expires_in' => 7200, 30 | ], 200); 31 | } 32 | 33 | if ($request->hasHeader('Authorization', 'Basic dXNlcjpwYXNzd29yZA==')) { 34 | return Http::response([ 35 | 'token_type' => 'Bearer', 36 | 'access_token' => 'this_is_my_access_token_from_basic_auth', 37 | 'scope' => 'scope1 scope2', 38 | 'expires_in' => 7200, 39 | ], 200); 40 | } 41 | } 42 | 43 | if ( 44 | $request->url() === 'https://example.com/api' 45 | && $request->hasHeader('Authorization') 46 | ) { 47 | return Http::response([ 48 | 'data' => 'some data with bearer token', 49 | 'token' => $request->header('Authorization')[0], 50 | ], 200); 51 | } 52 | 53 | if (Str::of($request->url())->startsWith('https://example.com/api?token=')) { 54 | $token = UrlHelper::parseQueryTokenFromUrl($request->url()); 55 | 56 | return Http::response([ 57 | 'data' => 'some data with query string token', 58 | 'token' => $token, 59 | ], 200); 60 | } 61 | 62 | return Http::response([], 200); 63 | } 64 | ); 65 | } 66 | 67 | protected function defineEnvironment($app) 68 | { 69 | // Setup default database to use sqlite :memory: 70 | tap($app['config'], function (Repository $config) { 71 | 72 | //dd($config->get('cache.stores')); 73 | 74 | // Setup queue database connections. 75 | $configData = [ 76 | 'database.default' => 'testbench', 77 | 'database.connections.testbench' => [ 78 | 'driver' => 'sqlite', 79 | 'database' => ':memory:', 80 | 'prefix' => '', 81 | ], 82 | 'queue.batching.database' => 'testbench', 83 | 'queue.failed.database' => 'testbench', 84 | 85 | //'cache.stores.array' 86 | 87 | ]; 88 | 89 | foreach ($configData as $key => $value) { 90 | $config->set($key, $value); 91 | } 92 | }); 93 | } 94 | 95 | protected function usesMySqlConnection($app) 96 | { 97 | $app['config']->set('database.default', 'mysql'); 98 | } 99 | 100 | protected function getPackageProviders($app): array 101 | { 102 | return [ 103 | LaravelHttpOAuthHelperServiceProvider::class, 104 | ]; 105 | } 106 | 107 | public static function callMethod($obj, $name, array $args) 108 | { 109 | $class = new \ReflectionClass($obj); 110 | 111 | return $class->getMethod($name)->invokeArgs($obj, $args); 112 | } 113 | 114 | public static function getProperty($object, $property) 115 | { 116 | $reflectedClass = new \ReflectionClass($object); 117 | $reflection = $reflectedClass->getProperty($property); 118 | $reflection->setAccessible(true); 119 | 120 | return $reflection->getValue($object); 121 | } 122 | 123 | protected function clearExistingFakes(): static 124 | { 125 | $reflection = new \ReflectionObject(Http::getFacadeRoot()); 126 | $property = $reflection->getProperty('stubCallbacks'); 127 | $property->setAccessible(true); 128 | $property->setValue(Http::getFacadeRoot(), collect()); 129 | 130 | return $this; 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /tests/Unit/ArchTest.php: -------------------------------------------------------------------------------- 1 | preset()->laravel(); 4 | arch()->preset()->security()->ignoring('parse_str');; 5 | 6 | arch()->expect('dd')->not->toBeUsed(); 7 | -------------------------------------------------------------------------------- /tests/Unit/CredentialsTest.php: -------------------------------------------------------------------------------- 1 | expectException(InvalidArgumentException::class); 9 | $this->expectExceptionMessage('Invalid credentials. Check documentation/readme.'); 10 | 11 | $credentials = new Credentials( 12 | credentials: [ 13 | 'value1', 14 | 'value2', 15 | 'value3', 16 | 'value4', 17 | ], 18 | ); 19 | 20 | }); 21 | -------------------------------------------------------------------------------- /tests/Unit/MacroTest.php: -------------------------------------------------------------------------------- 1 | ['scope1', 'scope2']], 19 | )->get('https://example.com/api'); 20 | 21 | Cache::shouldReceive('get') 22 | ->with('test', '', \Closure::class) 23 | ->andReturn(''); 24 | 25 | Http::assertSent(static function (Request $request) { 26 | return $request->hasHeader('Authorization', 'Bearer this_is_my_access_token_from_body_refresh_token') && $request->url() === 'https://example.com/api'; 27 | }); 28 | }); 29 | test('macro with shorthand client credentials', function () { 30 | $response = Http::withRefreshToken( 31 | 'https://example.com/oauth/token', 32 | [ 33 | 'my_client_id', 'my_client_secret', 34 | ], 35 | [ 36 | 'scopes' => ['scope1', 'scope2'], 37 | 'authType' => 'basic', 38 | ], 39 | )->get('https://example.com/api'); 40 | 41 | expect($response->json()['data'])->toBe('some data with bearer token'); 42 | 43 | Http::assertSentInOrder([ 44 | function (Request $request) { 45 | return $request->hasHeader('Authorization', 'Basic bXlfY2xpZW50X2lkOm15X2NsaWVudF9zZWNyZXQ=') 46 | && $request->url() === 'https://example.com/oauth/token'; 47 | }, 48 | function (Request $request) { 49 | return $request->hasHeader('Authorization', 'Bearer this_is_my_access_token_from_body_refresh_token') 50 | && $request->url() === 'https://example.com/api'; 51 | }, 52 | ]); 53 | }); 54 | test('macro with refresh token in credentials object', function () { 55 | $response = Http::withRefreshToken( 56 | 'https://example.com/oauth/token', 57 | new Credentials( 58 | token: 'this_is_my_refresh_token', 59 | ), 60 | [ 61 | 'scopes' => ['scope1', 'scope2'], 62 | 'authType' => Credentials::AUTH_TYPE_BEARER, 63 | ] 64 | )->get('https://example.com/api'); 65 | 66 | expect($response->json()['data'])->toBe('some data with bearer token'); 67 | 68 | Http::assertSentInOrder([ 69 | function (Request $request) { 70 | return $request->hasHeader('Authorization', 'Bearer this_is_my_refresh_token') && $request->url() === 'https://example.com/oauth/token'; 71 | }, 72 | function (Request $request) { 73 | return $request->hasHeader('Authorization', 'Bearer this_is_my_access_token_from_body_refresh_token') && $request->url() === 'https://example.com/api'; 74 | }, 75 | ]); 76 | }); 77 | 78 | test('macro with client credentials in credentials object', function () { 79 | $response = Http::withRefreshToken( 80 | 'https://example.com/oauth/token', 81 | new Credentials( 82 | clientId: 'this_is_my_client_id', 83 | clientSecret: 'this_is_my_client_secret', 84 | ), 85 | [ 86 | 'scopes' => ['scope1', 'scope2'], 87 | 'tokenType' => AccessToken::TOKEN_TYPE_QUERY, 88 | 'authType' => Credentials::AUTH_TYPE_BASIC, 89 | ], 90 | )->get('https://example.com/api'); 91 | 92 | Http::assertSentInOrder([ 93 | function (Request $request) { 94 | return $request->hasHeader('Authorization', 'Basic dGhpc19pc19teV9jbGllbnRfaWQ6dGhpc19pc19teV9jbGllbnRfc2VjcmV0') 95 | && $request->url() === 'https://example.com/oauth/token'; 96 | }, 97 | function (Request $request) { 98 | return $request->url() === 'https://example.com/api?token=this_is_my_access_token_from_body_refresh_token'; 99 | }, 100 | ]); 101 | }); 102 | 103 | test('macro with custom cache store', function () { 104 | 105 | Cache::clear(); 106 | Cache::spy(); 107 | 108 | Cache::shouldReceive('store') 109 | ->with('file') 110 | ->andReturn(new FileStore(app()['files'], 'tests/cache')); 111 | 112 | $response = Http::withRefreshToken( 113 | 'https://example.com/oauth/token', 114 | new Credentials( 115 | clientId: 'this_is_my_client_id', 116 | clientSecret: 'this_is_my_client_secret', 117 | ), 118 | [ 119 | 'scopes' => ['scope1', 'scope2'], 120 | 'tokenType' => AccessToken::TOKEN_TYPE_BEARER, 121 | 'authType' => Credentials::AUTH_TYPE_BASIC, 122 | 'cacheDriver' => 'file', 123 | 'cacheKey' => 'my_cache_key', 124 | ], 125 | )->get('https://example.com/api'); 126 | 127 | $data = $response->json(); 128 | 129 | expect($data['data'])->toBe('some data with bearer token') 130 | ->and($data['token'])->toBe('Bearer this_is_my_access_token_from_body_refresh_token'); 131 | }); 132 | -------------------------------------------------------------------------------- /tests/Unit/OptionsTest.php: -------------------------------------------------------------------------------- 1 | expectException(\Illuminate\Validation\ValidationException::class); 12 | $this->expectExceptionMessage('The selected grant type is invalid'); 13 | 14 | new Options( 15 | grantType: 'invalid', 16 | tokenType: AccessToken::TOKEN_TYPE_BEARER, 17 | ); 18 | }); 19 | 20 | it('validates tokenType when creating an option object', function () { 21 | $this->expectException(\Illuminate\Validation\ValidationException::class); 22 | $this->expectExceptionMessage('The selected token type is invalid'); 23 | 24 | new Options( 25 | grantType: Credentials::GRANT_TYPE_CLIENT_CREDENTIALS, 26 | tokenType: 'invalid', 27 | ); 28 | }); 29 | 30 | it('validates authType when creating an option object', function () { 31 | $this->expectException(ValidationException::class); 32 | $this->expectExceptionMessage('The selected auth type is invalid'); 33 | 34 | new Options( 35 | authType: 'invalid', 36 | ); 37 | }); 38 | 39 | it('checks for integers in scopes when creating an option object', function () { 40 | $this->expectException(ValidationException::class); 41 | $this->expectExceptionMessage('The scopes.1 field must be a string.'); 42 | 43 | new Options( 44 | scopes: ['valid', 1], 45 | ); 46 | }); 47 | it('checks for objects in scopes when creating an option object', function () { 48 | 49 | $this->expectException(ValidationException::class); 50 | $this->expectExceptionMessage('The scopes.2 field must be a string.'); 51 | 52 | new Options( 53 | scopes: ['valid', 'also_valid', new stdClass], 54 | ); 55 | }); 56 | 57 | it('can create an option object', function () { 58 | $this->expectException(\Illuminate\Validation\ValidationException::class); 59 | $this->expectExceptionMessage('The selected token type is invalid'); 60 | 61 | new Options( 62 | grantType: Credentials::GRANT_TYPE_CLIENT_CREDENTIALS, 63 | tokenType: 'invalid', 64 | ); 65 | }); 66 | -------------------------------------------------------------------------------- /tests/Unit/RefreshTokenTest.php: -------------------------------------------------------------------------------- 1 | getAccessToken())->toEqual('this_is_my_access_token_from_body_refresh_token'); 35 | Http::assertSent(static function (Request $request) { 36 | return $request->hasHeader('Authorization', 'Basic Y2xpZW50X2lkOmNsaWVudF9zZWNyZXQ=') 37 | && $request->url() === 'https://example.com/oauth/token' 38 | && $request['grant_type'] === 'client_credentials' 39 | && $request['scope'] === 'scope1 scope2'; 40 | }); 41 | }); 42 | 43 | test('refresh token basic with invalid credentials', function () { 44 | $this->expectException(\InvalidArgumentException::class); 45 | $this->expectExceptionMessage('Basic auth requires client id and client secret. Check documentation/readme.'); 46 | 47 | $accessToken = app(RefreshToken::class)( 48 | 'https://example.com/oauth/token', 49 | new Credentials([ 50 | 'token', 51 | ]), 52 | new Options( 53 | scopes: ['scope1', 'scope2'], 54 | authType: Credentials::AUTH_TYPE_BASIC, 55 | grantType: 'client_credentials', 56 | ), 57 | ); 58 | }); 59 | 60 | test('refresh token body', function () { 61 | Cache::clear(); 62 | $accessToken = app(RefreshToken::class)( 63 | 'https://example.com/oauth/token', 64 | new Credentials( 65 | 'my_refresh_token', 66 | ), 67 | new Options( 68 | scopes: ['scope1', 'scope2'], 69 | authType: Credentials::AUTH_TYPE_BODY, 70 | grantType: 'password_credentials', 71 | ), 72 | ); 73 | 74 | expect($accessToken->getAccessToken())->toEqual('this_is_my_access_token_from_body_refresh_token'); 75 | Http::assertSent(static function (Request $request) { 76 | return $request->url() === 'https://example.com/oauth/token' 77 | && $request['grant_type'] === 'password_credentials' 78 | && $request['scope'] === 'scope1 scope2' 79 | && $request['token'] === 'my_refresh_token'; 80 | }); 81 | }); 82 | 83 | test('client pair body', function () { 84 | Cache::clear(); 85 | $accessToken = app(RefreshToken::class)( 86 | 'https://example.com/oauth/token', 87 | new Credentials([ 88 | 'my_client_id', 89 | 'my_client_secret', 90 | ], 91 | ), 92 | new Options( 93 | scopes: ['scope1', 'scope2'], 94 | authType: Credentials::AUTH_TYPE_BODY, 95 | grantType: 'password_credentials' 96 | ), 97 | ); 98 | 99 | expect($accessToken->getAccessToken())->toEqual('this_is_my_access_token_from_body_refresh_token'); 100 | Http::assertSent(static function (Request $request) { 101 | return $request->url() === 'https://example.com/oauth/token' 102 | && $request['grant_type'] === 'password_credentials' 103 | && $request['scope'] === 'scope1 scope2' 104 | && $request['client_id'] === 'my_client_id' 105 | && $request['client_secret'] === 'my_client_secret'; 106 | }); 107 | }); 108 | 109 | test('refresh token custom', function () { 110 | Cache::clear(); 111 | 112 | $callback = fn (PendingRequest $httpClient) => $httpClient->withHeader('Authorization', 'my_custom_token'); 113 | 114 | $accessToken = app(RefreshToken::class)( 115 | 'https://example.com/oauth/token', 116 | new Credentials( 117 | fn (PendingRequest $httpClient) => $httpClient->withHeader('Authorization', 'my_custom_token'), 118 | ), 119 | new Options( 120 | scopes: ['scope1', 'scope2'], 121 | grantType: 'password_credentials', 122 | tokenType: AccessToken::TOKEN_TYPE_CUSTOM, 123 | tokenTypeCustomCallback: $callback, 124 | ), 125 | ); 126 | 127 | expect($accessToken->getAccessToken())->toEqual('this_is_my_access_token_from_body_refresh_token') 128 | ->and($accessToken->getCustomCallback())->toEqual($callback); 129 | Http::assertSent(static function (Request $request) { 130 | return $request->url() === 'https://example.com/oauth/token' 131 | && $request->hasHeader('Authorization', 'my_custom_token') 132 | && $request['grant_type'] === 'password_credentials' 133 | && $request['scope'] === 'scope1 scope2'; 134 | }); 135 | }); 136 | 137 | test('refresh token with expiry', function () { 138 | Cache::spy(); 139 | 140 | $accessToken = app(RefreshToken::class)( 141 | 'https://example.com/oauth/token', 142 | new Credentials([ 143 | 'my_client_id', 144 | 'my_client_secret', 145 | ]), 146 | new Options( 147 | scopes: ['scope1', 'scope2'], 148 | authType: Credentials::AUTH_TYPE_BODY, 149 | expires: 300, 150 | ), 151 | ); 152 | 153 | expect($accessToken->getAccessToken())->toEqual('this_is_my_access_token_from_body_refresh_token'); 154 | expect($accessToken->getExpiresIn())->toBeBetween(235, 240); 155 | Http::assertSent(static function (Request $request) { 156 | return $request->url() === 'https://example.com/oauth/token' 157 | && $request['grant_type'] === 'client_credentials' 158 | && $request['scope'] === 'scope1 scope2' 159 | && $request['client_id'] === 'my_client_id' 160 | && $request['client_secret'] === 'my_client_secret'; 161 | }); 162 | }); 163 | 164 | test('refresh token with expiry callback', function () { 165 | $accessToken = app(RefreshToken::class)( 166 | 'https://example.com/oauth/token', 167 | new Credentials([ 168 | 'my_client_id', 169 | 'my_client_secret', 170 | ]), 171 | new Options( 172 | scopes: ['scope1', 'scope2'], 173 | expires: static function ($response) { 174 | return $response->json()['expires_in']; 175 | }, 176 | ), 177 | ); 178 | 179 | expect($accessToken->getAccessToken())->toEqual('this_is_my_access_token_from_body_refresh_token'); 180 | Http::assertSent(static function (Request $request) { 181 | return $request->url() === 'https://example.com/oauth/token' && $request['grant_type'] === 'client_credentials' && $request['scope'] === 'scope1 scope2'; 182 | }); 183 | }); 184 | 185 | test('get access token from custom key', function () { 186 | $this->clearExistingFakes(); 187 | Http::fake([ 188 | 'https://example.com/oauth/token' => Http::response([ 189 | 'token_type' => 'Bearer', 190 | 'custom_access_token' => 'my_custom_access_token', 191 | 'scope' => 'scope1 scope2', 192 | 'expires_in' => 7200, 193 | ], 200), 194 | ]); 195 | 196 | Cache::spy(); 197 | $accessToken = app(RefreshToken::class)( 198 | 'https://example.com/oauth/token', 199 | new Credentials([ 200 | 'my_client_id', 201 | 'my_client_secret', 202 | ]), 203 | new Options( 204 | scopes: ['scope1', 'scope2'], 205 | accessToken: static function ($response) { 206 | return $response->json()['custom_access_token']; 207 | }, 208 | authType: Credentials::AUTH_TYPE_BASIC, 209 | ), 210 | ); 211 | 212 | expect($accessToken->getAccessToken())->toEqual('my_custom_access_token'); 213 | 214 | Http::assertSent(static function (Request $request) { 215 | return $request->url() === 'https://example.com/oauth/token' 216 | && $request->hasHeader('Authorization', 'Basic bXlfY2xpZW50X2lkOm15X2NsaWVudF9zZWNyZXQ=') 217 | && $request['grant_type'] === 'client_credentials' 218 | && $request['scope'] === 'scope1 scope2'; 219 | }); 220 | }); 221 | 222 | test('throws exception with invalid credentials', function () { 223 | $this->expectException(\InvalidArgumentException::class); 224 | $this->expectExceptionMessage('Invalid credentials. Check documentation/readme.'); 225 | 226 | app(RefreshToken::class)( 227 | 'https://example.com/oauth/token', 228 | new Credentials([ 229 | 'my_client_id', 230 | 'my_client_secret', 231 | 'invalid', 232 | ]), 233 | new Options( 234 | scopes: ['scope1', 'scope2'], 235 | ), 236 | ); 237 | }); 238 | 239 | test('throws exception with an invalid auth type', function () { 240 | $this->expectException(\Illuminate\Validation\ValidationException::class); 241 | $this->expectExceptionMessage('The selected auth type is invalid.'); 242 | 243 | app(RefreshToken::class)( 244 | 'https://example.com/oauth/token', 245 | new Credentials([ 246 | 'my_client_id', 247 | 'my_client_secret', 248 | ]), 249 | new Options( 250 | scopes: ['scope1', 'scope2'], 251 | authType: 'invalid', 252 | ), 253 | ); 254 | }); 255 | 256 | test('throws exception with an invalid token type', function () { 257 | $this->expectException(\InvalidArgumentException::class); 258 | $this->expectExceptionMessage('customCallback must be set when using AUTH_TYPE_CUSTOM'); 259 | 260 | app(RefreshToken::class)( 261 | 'https://example.com/oauth/token', 262 | new Credentials(['my_token']), 263 | new Options( 264 | scopes: ['scope1', 'scope2'], 265 | tokenType: AccessToken::TOKEN_TYPE_CUSTOM, 266 | ), 267 | ); 268 | }); 269 | 270 | test('access token getters', function () { 271 | 272 | $accessToken = app(RefreshToken::class)( 273 | 'https://example.com/oauth/token', 274 | new Credentials(['my_token']), 275 | new Options( 276 | scopes: ['scope1', 'scope2'], 277 | ), 278 | ); 279 | 280 | expect($accessToken->getAccessToken())->toBe('this_is_my_access_token_from_body_refresh_token') 281 | ->and($accessToken->getExpiresIn())->toBeBetween(3535, 3540) 282 | ->and($accessToken->getExpiresAt())->toBeInstanceOf(Carbon::class) 283 | ->and($accessToken->getCustomCallback())->toBeNull(); 284 | }); 285 | 286 | test('custom token type', function () { 287 | 288 | $accessToken = app(RefreshToken::class)( 289 | 'https://example.com/oauth/token', 290 | new Credentials(['my_token']), 291 | new Options( 292 | scopes: ['scope1', 'scope2'], 293 | tokenType: AccessToken::TOKEN_TYPE_CUSTOM, 294 | tokenTypeCustomCallback: function (PendingRequest $httpClient) { 295 | return $httpClient->withHeader('MyCustomAuthHeader', 'my_custom_token'); 296 | } 297 | ), 298 | ); 299 | 300 | $httpClient = (new Factory)->createPendingRequest(); 301 | 302 | $httpClient = $accessToken->getHttpClient($httpClient); 303 | 304 | $options = $httpClient->getOptions(); 305 | 306 | expect($options['headers']['MyCustomAuthHeader'])->toBe('my_custom_token'); 307 | }); 308 | 309 | test('custom auth token type', function () { 310 | 311 | app(RefreshToken::class)( 312 | 'https://example.com/oauth/token', 313 | new Credentials(function (PendingRequest $httpClient) { 314 | return $httpClient->withHeader('MyCustomAuthHeader', 'my_custom_token'); 315 | }), 316 | new Options( 317 | scopes: ['scope1', 'scope2'], 318 | ), 319 | ); 320 | Http::assertSent(static function (Request $request) { 321 | return $request->hasHeader('MyCustomAuthHeader', 'my_custom_token') 322 | && $request->url() === 'https://example.com/oauth/token'; 323 | }); 324 | }); 325 | 326 | test('auth type query', function () { 327 | 328 | app(RefreshToken::class)( 329 | 'https://example.com/oauth/token', 330 | new Credentials('my_query_token'), 331 | new Options( 332 | scopes: ['scope1', 'scope2'], 333 | authType: Credentials::AUTH_TYPE_QUERY, 334 | tokenName: 'custom_token_name', 335 | accessToken: function (Response $response) { 336 | return UrlHelper::parseQueryTokenFromResponse($response, 'custom_token_name'); 337 | } 338 | ), 339 | ); 340 | Http::assertSent(function (Request $request) { 341 | 342 | $token = UrlHelper::parseQueryTokenFromUrl($request->url(), 'custom_token_name'); 343 | 344 | expect($token)->toBe('my_query_token'); 345 | 346 | return $request->url() === 'https://example.com/oauth/token?custom_token_name=my_query_token'; 347 | }); 348 | }); 349 | 350 | test('set token expiry with string key with date', function () { 351 | 352 | $this->clearExistingFakes(); 353 | 354 | /** @var Carbon $nowDate */ 355 | $nowDate = Carbon::create(2024, 11, 11, 11); 356 | 357 | Carbon::setTestNow($nowDate); 358 | 359 | Http::fake([ 360 | 'https://example.com/oauth/token' => Http::response([ 361 | 'token_type' => 'Bearer', 362 | 'access_token' => 'my_custom_access_token', 363 | 'scope' => 'scope1 scope2', 364 | 'expires_date' => $nowDate->addHour(), 365 | ], 200), 366 | ]); 367 | 368 | $accessToken = app(RefreshToken::class)( 369 | 'https://example.com/oauth/token', 370 | new Credentials('my_query_token'), 371 | new Options( 372 | scopes: ['scope1', 'scope2'], 373 | expires: 'expires_date', 374 | ), 375 | ); 376 | 377 | expect($accessToken->getExpiresAt()->timestamp)->toBe($nowDate->subMinute()->timestamp); 378 | }); 379 | 380 | test('set token expiry with string key with integer', function () { 381 | 382 | /** @var Carbon $nowDate */ 383 | $nowDate = Carbon::create(2024, 11, 11, 11); 384 | 385 | Carbon::setTestNow($nowDate); 386 | 387 | $accessToken = app(RefreshToken::class)( 388 | 'https://example.com/oauth/token', 389 | new Credentials('my_query_token'), 390 | new Options( 391 | scopes: ['scope1', 'scope2'], 392 | expires: 'expires_in', 393 | ), 394 | ); 395 | 396 | expect($accessToken->getExpiresAt()->timestamp)->toBe($nowDate->addSeconds(7200)->subMinute()->timestamp); 397 | }); 398 | 399 | test('set token expiry with carbon object', function () { 400 | 401 | /** @var Carbon $nowDate */ 402 | $nowDate = Carbon::create(2024, 11, 11, 11); 403 | 404 | Carbon::setTestNow($nowDate); 405 | 406 | $accessToken = app(RefreshToken::class)( 407 | 'https://example.com/oauth/token', 408 | new Credentials('my_query_token'), 409 | new Options( 410 | scopes: ['scope1', 'scope2'], 411 | expires: Carbon::now()->addHour(), 412 | ), 413 | ); 414 | 415 | expect($accessToken->getExpiresAt()->timestamp)->toBe($nowDate->addHour()->subMinute()->timestamp); 416 | }); 417 | 418 | test('invalid token expiry', function () { 419 | $this->expectException(\InvalidArgumentException::class); 420 | $this->expectExceptionMessage('Invalid expires option'); 421 | 422 | app(RefreshToken::class)( 423 | 'https://example.com/oauth/token', 424 | new Credentials('my_query_token'), 425 | new Options( 426 | scopes: ['scope1', 'scope2'], 427 | expires: function () { 428 | return new stdClass; 429 | }, 430 | ), 431 | ); 432 | }); 433 | 434 | })->done(assignee: 'pelmered'); 435 | -------------------------------------------------------------------------------- /tests/Unit/TokenStoreTest.php: -------------------------------------------------------------------------------- 1 | andReturnSelf(); 26 | $cacheRepositorySpy->shouldReceive('put')->times(1); 27 | $cacheRepositorySpy->shouldReceive('get') 28 | ->with($cacheKey) 29 | ->times(1) 30 | ->andReturn(null); 31 | 32 | $accessToken1 = TokenStore::get( 33 | 'https://example.com/oauth/token', 34 | new Credentials('my_token'), 35 | new Options( 36 | scopes: ['scope1', 'scope2'], 37 | ), 38 | ); 39 | 40 | $cacheRepositorySpy->shouldReceive('get') 41 | ->with($cacheKey) 42 | ->times(2) 43 | ->andReturn($accessToken1); 44 | 45 | $accessToken2 = TokenStore::get( 46 | 'https://example.com/oauth/token', 47 | new Credentials('my_token'), 48 | new Options( 49 | scopes: ['scope1', 'scope2'], 50 | ), 51 | ); 52 | $accessToken3 = TokenStore::get( 53 | 'https://example.com/oauth/token', 54 | new Credentials('my_token'), 55 | new Options( 56 | scopes: ['scope1', 'scope2'], 57 | ), 58 | ); 59 | 60 | isSameAccessToken($accessToken1, $accessToken2, 0); 61 | isSameAccessToken($accessToken1, $accessToken3, 0); 62 | }); 63 | 64 | it('reads and stores a token in cache with custom cache key and driver', function () { 65 | 66 | Cache::clear(); 67 | 68 | $cacheStore = 'file'; 69 | $cacheKey = 'custom_cache_key'; 70 | 71 | $cacheManager = app('cache'); 72 | $cacheManagerSpy = Mockery::spy($cacheManager); 73 | Cache::swap($cacheManagerSpy); 74 | 75 | $cacheRepository = Cache::driver($cacheStore); // or: $cacheRepository = app('cache.store'); 76 | $cacheRepositorySpy = Mockery::spy($cacheRepository); 77 | Cache::swap($cacheRepositorySpy); 78 | 79 | Cache::shouldReceive('store')->with($cacheStore)->andReturnSelf(); 80 | $cacheRepositorySpy->shouldReceive('put') 81 | ->with($cacheKey, Mockery::type(AccessToken::class), Mockery::any()) 82 | ->times(1); 83 | $cacheRepositorySpy->shouldReceive('get') 84 | ->with($cacheKey) 85 | ->times(1) 86 | ->andReturn(null); 87 | 88 | $accessToken1 = TokenStore::get( 89 | 'https://example.com/oauth/token', 90 | new Credentials( 91 | clientId: 'this_is_my_client_id', 92 | clientSecret: 'this_is_my_client_secret', 93 | ), 94 | new Options( 95 | scopes: ['scope1', 'scope2'], 96 | authType: Credentials::AUTH_TYPE_BASIC, 97 | cacheKey: $cacheKey, 98 | cacheDriver: $cacheStore 99 | ), 100 | ); 101 | 102 | $cacheRepositorySpy->shouldReceive('get') 103 | ->with($cacheKey) 104 | ->times(1) 105 | ->andReturn($accessToken1); 106 | 107 | $accessToken2 = TokenStore::get( 108 | 'https://example.com/oauth/token', 109 | new Credentials( 110 | clientId: 'this_is_my_client_id', 111 | clientSecret: 'this_is_my_client_secret', 112 | ), 113 | new Options( 114 | scopes: ['scope1', 'scope2'], 115 | authType: Credentials::AUTH_TYPE_BASIC, 116 | cacheKey: $cacheKey, 117 | cacheDriver: $cacheStore 118 | ), 119 | ); 120 | 121 | isSameAccessToken($accessToken1, $accessToken2); 122 | }); 123 | it('reads and stores a token in cache with custom cache driver2', function () { 124 | 125 | Cache::clear(); 126 | 127 | $cacheStore = 'file'; 128 | $cacheKey = 'custom_cache_key'; 129 | $cacheStoreNotUsed = 'array'; 130 | 131 | $accessToken = TokenStore::get( 132 | 'https://example.com/oauth/token', 133 | new Credentials( 134 | clientId: 'this_is_my_client_id', 135 | clientSecret: 'this_is_my_client_secret', 136 | ), 137 | new Options( 138 | scopes: ['scope1', 'scope2'], 139 | authType: Credentials::AUTH_TYPE_BASIC, 140 | cacheKey: $cacheKey, 141 | cacheDriver: $cacheStore 142 | ), 143 | ); 144 | 145 | $accessToken2 = Cache::store($cacheStore)->get($cacheKey); 146 | $accessToken3 = Cache::store($cacheStoreNotUsed)->get($cacheKey); 147 | 148 | isSameAccessToken($accessToken, $accessToken2); 149 | 150 | expect($accessToken3)->toBeNull(); 151 | }); 152 | -------------------------------------------------------------------------------- /tests/Unit/UrlHelperTest.php: -------------------------------------------------------------------------------- 1 | toEqual($result); 13 | })->with([ 14 | 'normal' => [[['https://example.com/oauth/token?token=this_is_my_access_token_from_url', []], 'token'], 'this_is_my_access_token_from_url'], 15 | 'empty' => [[['https://example.com/oauth/token?token=', []], 'token'], null], 16 | 'custom' => [[['https://example.com/oauth/token?custom=this_is_my_access_token_from_url', []], 'custom'], 'this_is_my_access_token_from_url'], 17 | 'wrong' => [[['https://example.com/oauth/token?token=this_is_my_access_token_from_url', []], 'wrong'], null], 18 | ]); 19 | 20 | test('UrLHelper parseQueryTokenFromUrl', function (array $params, ?string $result) { 21 | $token = UrlHelper::parseQueryTokenFromUrl(...$params); 22 | 23 | expect($token)->toEqual($result); 24 | })->with([ 25 | 'normal' => [['https://example.com/oauth/token?token=this_is_my_access_token_from_url'], 'this_is_my_access_token_from_url'], 26 | 'empty' => [['https://example.com/oauth/token'], null], 27 | 'custom' => [['https://example.com/oauth/token?custom=this_is_my_access_token_from_url', 'custom'], 'this_is_my_access_token_from_url'], 28 | 'wrong' => [['https://example.com/oauth/token?token=this_is_my_access_token_from_url', 'wrong'], null], 29 | ]); 30 | 31 | test('UrLHelper parseTokenFromQueryString', function ($params, $result) { 32 | 33 | $token = UrlHelper::parseTokenFromQueryString(...$params); 34 | 35 | expect($token)->toEqual($result); 36 | })->with([ 37 | 'normal' => [['token=this_is_my_access_token_from_url'], 'this_is_my_access_token_from_url'], 38 | 'invalid query string' => [['something_random'], null], 39 | 'custom key' => [['key=this_is_another_token_from_url', 'key'], 'this_is_another_token_from_url'], 40 | 'wrong key specified' => [['key=this_is_another_token_from_url', 'other_key'], null], 41 | ]); 42 | --------------------------------------------------------------------------------