├── .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 | [](https://packagist.org/packages/pelmered/laravel-http-client-auth-helper)
30 | [](//packagist.org/packages/pelmered/laravel-http-client-auth-helper)
31 | [](//packagist.org/packages/pelmered/laravel-http-client-auth-helper)
32 | [](https://packagist.org/packages/pelmered/laravel-http-client-auth-helper)
33 |
34 | [](https://github.com/pelmered/laravel-http-client-auth-helper/actions/workflows/tests.yml)
35 | [](https://scrutinizer-ci.com/g/pelmered/laravel-http-client-auth-helper/build-status/main)
36 | [](https://scrutinizer-ci.com/g/pelmered/laravel-http-client-auth-helper/?branch=master)
37 | [](https://scrutinizer-ci.com/g/pelmered/laravel-http-client-auth-helper/?branch=main)
38 |
39 | [](https://github.com/pelmered/filament-money-field/actions/workflows/tests.yml)
40 | [](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 |
--------------------------------------------------------------------------------