Skip to content

Commit 5c9af4c

Browse files
committed
✨ +Reddit provider
1 parent 342608d commit 5c9af4c

File tree

8 files changed

+300
-1
lines changed

8 files changed

+300
-1
lines changed

.config/.env_example

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,12 @@ PAYPAL_SANDBOX_SECRET=
188188
PAYPAL_SANDBOX_CALLBACK_URL=
189189
#PAYPAL_SANDBOX_TESTUSER=
190190

191+
# https://www.reddit.com/prefs/apps/
192+
REDDIT_KEY=
193+
REDDIT_SECRET=
194+
REDDIT_CALLBACK_URL=
195+
#REDDIT_TESTUSER=
196+
191197
# https://api.slack.com/apps/
192198
SLACK_KEY=
193199
SLACK_SECRET=

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,7 @@ Note: replace `dev-main` with a [version constraint](https://getcomposer.org/doc
129129
| [Patreon](https://docs.patreon.com/) | [link](https://www.patreon.com/portal/registration/register-clients) | | 2 ||| | || |
130130
| [PayPal](https://developer.paypal.com/docs/connect-with-paypal/reference/) | [link](https://developer.paypal.com/developer/applications/) | | 2 ||| ||| |
131131
| [PayPalSandbox](https://developer.paypal.com/docs/connect-with-paypal/reference/) | [link](https://developer.paypal.com/developer/applications/) | | 2 ||| ||| |
132+
| [Reddit](https://www.reddit.com/dev/api) | [link](https://www.reddit.com/prefs/apps/) | [link](https://www.reddit.com/settings/privacy) | 2 ||| ||||
132133
| [Slack](https://api.slack.com) | [link](https://api.slack.com/apps) | [link](https://slack.com/apps/manage) | 2 ||| | | | |
133134
| [SoundCloud](https://developers.soundcloud.com/) | [link](https://soundcloud.com/you/apps) | [link](https://soundcloud.com/settings/connections) | 2 || | ||| |
134135
| [Spotify](https://developer.spotify.com/documentation/web-api/) | [link](https://developer.spotify.com/dashboard) | [link](https://www.spotify.com/account/apps/) | 2 ||| ||| |

docs/Basics/Overview.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ fully [PSR-7](https://www.php-fig.org/psr/psr-7/)/[PSR-17](https://www.php-fig.o
6868
| [Patreon](https://docs.patreon.com/) | [link](https://www.patreon.com/portal/registration/register-clients) | | 2 ||| | || |
6969
| [PayPal](https://developer.paypal.com/docs/connect-with-paypal/reference/) | [link](https://developer.paypal.com/developer/applications/) | | 2 ||| ||| |
7070
| [PayPalSandbox](https://developer.paypal.com/docs/connect-with-paypal/reference/) | [link](https://developer.paypal.com/developer/applications/) | | 2 ||| ||| |
71+
| [Reddit](https://www.reddit.com/dev/api) | [link](https://www.reddit.com/prefs/apps/) | [link](https://www.reddit.com/settings/privacy) | 2 ||| ||||
7172
| [Slack](https://api.slack.com) | [link](https://api.slack.com/apps) | [link](https://slack.com/apps/manage) | 2 ||| | | | |
7273
| [SoundCloud](https://developers.soundcloud.com/) | [link](https://soundcloud.com/you/apps) | [link](https://soundcloud.com/settings/connections) | 2 || | ||| |
7374
| [Spotify](https://developer.spotify.com/documentation/web-api/) | [link](https://developer.spotify.com/dashboard) | [link](https://www.spotify.com/account/apps/) | 2 ||| ||| |

examples/get-token/Reddit.php

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
<?php
2+
/**
3+
* @link https://github.com/reddit-archive/reddit/wiki/OAuth2
4+
*
5+
* @created 09.04.2024
6+
* @author smiley <smiley@chillerlan.net>
7+
* @copyright 2024 smiley
8+
* @license MIT
9+
*/
10+
declare(strict_types=1);
11+
12+
use chillerlan\OAuth\Providers\Reddit;
13+
14+
$ENVVAR ??= 'REDDIT';
15+
$PARAMS ??= ['duration' => 'permanent'];
16+
17+
require_once __DIR__.'/../provider-example-common.php';
18+
19+
/** @var \OAuthExampleProviderFactory $factory */
20+
$provider = $factory->getProvider(Reddit::class, $ENVVAR);
21+
22+
require_once __DIR__.'/_flow-oauth2.php';
23+
24+
exit;

src/Core/OAuth2Provider.php

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,9 @@ protected function parseTokenResponse(ResponseInterface $response):AccessToken{
100100
}
101101

102102
// deezer: "error_reason", paypal: "message" (along with "links", "name")
103-
foreach(['error', 'error_description', 'error_reason', 'message'] as $field){
103+
// reddit sends "message" and "error" as int, which will throw a TypeError when handed into the exception
104+
// detection order changed accordingly
105+
foreach(['message', 'error', 'error_description', 'error_reason'] as $field){
104106
if(isset($data[$field])){
105107

106108
if(in_array($response->getStatusCode(), [400, 401, 403], true)){

src/Providers/Reddit.php

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
<?php
2+
/**
3+
* Class Reddit
4+
*
5+
* @created 09.04.2024
6+
* @author smiley <smiley@chillerlan.net>
7+
* @copyright 2024 smiley
8+
* @license MIT
9+
*
10+
* @noinspection PhpUnused
11+
*/
12+
declare(strict_types=1);
13+
14+
namespace chillerlan\OAuth\Providers;
15+
16+
use chillerlan\HTTP\Utils\QueryUtil;
17+
use chillerlan\OAuth\Core\{
18+
AccessToken, AuthenticatedUser, ClientCredentials, CSRFToken, OAuth2Interface,
19+
OAuth2Provider, TokenInvalidate, TokenRefresh, UserInfo
20+
};
21+
use Psr\Http\Message\ResponseInterface;
22+
use function sodium_bin2base64, sprintf;
23+
use const PHP_QUERY_RFC1738, SODIUM_BASE64_VARIANT_ORIGINAL;
24+
25+
/**
26+
* @see https://github.com/reddit-archive/reddit/wiki/OAuth2
27+
* @see https://github.com/reddit-archive/reddit/wiki/API
28+
* @see https://support.reddithelp.com/hc/en-us/articles/16160319875092-Reddit-Data-API-Wiki
29+
* @see https://www.reddit.com/dev/api
30+
*/
31+
class Reddit extends OAuth2Provider implements ClientCredentials, CSRFToken, TokenRefresh, TokenInvalidate, UserInfo{
32+
33+
public const SCOPE_ACCOUNT = 'account';
34+
public const SCOPE_CREDDITS = 'creddits';
35+
public const SCOPE_EDIT = 'edit';
36+
public const SCOPE_FLAIR = 'flair';
37+
public const SCOPE_HISTORY = 'history';
38+
public const SCOPE_IDENTITY = 'identity';
39+
public const SCOPE_LIVEMANAGE = 'livemanage';
40+
public const SCOPE_MODCONFIG = 'modconfig';
41+
public const SCOPE_MODCONTRIBUTORS = 'modcontributors';
42+
public const SCOPE_MODFLAIR = 'modflair';
43+
public const SCOPE_MODLOG = 'modlog';
44+
public const SCOPE_MODMAIL = 'modmail';
45+
public const SCOPE_MODNOTE = 'modnote';
46+
public const SCOPE_MODOTHERS = 'modothers';
47+
public const SCOPE_MODPOSTS = 'modposts';
48+
public const SCOPE_MODSELF = 'modself';
49+
public const SCOPE_MODTRAFFIC = 'modtraffic';
50+
public const SCOPE_MODWIKI = 'modwiki';
51+
public const SCOPE_MYSUBREDDITS = 'mysubreddits';
52+
public const SCOPE_PRIVATEMESSAGES = 'privatemessages';
53+
public const SCOPE_READ = 'read';
54+
public const SCOPE_REPORT = 'report';
55+
public const SCOPE_SAVE = 'save';
56+
public const SCOPE_STRUCTUREDSTYLES = 'structuredstyles';
57+
public const SCOPE_SUBMIT = 'submit';
58+
public const SCOPE_SUBSCRIBE = 'subscribe';
59+
public const SCOPE_VOTE = 'vote';
60+
public const SCOPE_WIKIEDIT = 'wikiedit';
61+
public const SCOPE_WIKIREAD = 'wikiread';
62+
63+
public const DEFAULT_SCOPES = [
64+
self::SCOPE_ACCOUNT,
65+
self::SCOPE_IDENTITY,
66+
self::SCOPE_READ,
67+
];
68+
69+
public const USER_AGENT = OAuth2Interface::USER_AGENT.' (by /u/chillerlan)';
70+
71+
public const HEADERS_AUTH = [
72+
'User-Agent' => self::USER_AGENT,
73+
];
74+
75+
public const HEADERS_API = [
76+
'User-Agent' => self::USER_AGENT,
77+
];
78+
79+
protected string $authorizationURL = 'https://www.reddit.com/api/v1/authorize';
80+
protected string $accessTokenURL = 'https://www.reddit.com/api/v1/access_token';
81+
protected string $apiURL = 'https://oauth.reddit.com/api';
82+
protected string $revokeURL = 'https://www.reddit.com/api/v1/revoke_token';
83+
protected string|null $apiDocs = 'https://www.reddit.com/dev/api';
84+
protected string|null $applicationURL = 'https://www.reddit.com/prefs/apps/';
85+
protected string|null $userRevokeURL = 'https://www.reddit.com/settings/privacy';
86+
87+
/**
88+
* @inheritDoc
89+
*/
90+
protected function getAccessTokenRequestBodyParams(string $code):array{
91+
return [
92+
'code' => $code,
93+
'grant_type' => 'authorization_code',
94+
'redirect_uri' => $this->options->callbackURL,
95+
];
96+
}
97+
98+
/**
99+
* @inheritDoc
100+
*/
101+
protected function sendAccessTokenRequest(string $url, array $body):ResponseInterface{
102+
$auth = sodium_bin2base64(sprintf('%s:%s', $this->options->key, $this->options->secret), SODIUM_BASE64_VARIANT_ORIGINAL);
103+
104+
$request = $this->requestFactory
105+
->createRequest('POST', $url)
106+
->withHeader('Accept-Encoding', 'identity')
107+
->withHeader('Authorization', sprintf('Basic %s', $auth))
108+
->withHeader('Content-Type', 'application/x-www-form-urlencoded')
109+
->withBody($this->streamFactory->createStream(QueryUtil::build($body, PHP_QUERY_RFC1738)))
110+
;
111+
112+
return $this->http->sendRequest($request);
113+
}
114+
115+
/**
116+
* @inheritDoc
117+
* @codeCoverageIgnore
118+
*/
119+
public function me():AuthenticatedUser{
120+
$json = $this->getMeResponseData('/v1/me');
121+
122+
$userdata = [
123+
'data' => $json,
124+
'avatar' => $json['subreddit']['icon_img'],
125+
'handle' => $json['name'],
126+
'displayName' => $json['subreddit']['title'],
127+
'id' => $json['id'],
128+
'url' => sprintf('https://www.reddit.com%s', $json['subreddit']['url']),
129+
];
130+
131+
return new AuthenticatedUser($userdata);
132+
}
133+
134+
/**
135+
* @see https://github.com/reddit-archive/reddit/wiki/OAuth2#manually-revoking-a-token
136+
* @inheritDoc
137+
*/
138+
public function invalidateAccessToken(AccessToken $token = null):bool{
139+
$tokenToInvalidate = ($token ?? $this->storage->getAccessToken($this->name));
140+
141+
$bodyParams = [
142+
'token' => $tokenToInvalidate->accessToken,
143+
'token_type_hint' => 'access_token',
144+
];
145+
146+
$auth = sodium_bin2base64(sprintf('%s:%s', $this->options->key, $this->options->secret), SODIUM_BASE64_VARIANT_ORIGINAL);
147+
148+
$request = $this->requestFactory
149+
->createRequest('POST', $this->revokeURL)
150+
->withHeader('Authorization', sprintf('Basic %s', $auth))
151+
->withHeader('Content-Type', 'application/x-www-form-urlencoded')
152+
;
153+
154+
$request = $this->setRequestBody($bodyParams, $request);
155+
$response = $this->http->sendRequest($request);
156+
157+
if($response->getStatusCode() === 204){
158+
159+
if($token === null){
160+
$this->storage->clearAccessToken($this->name);
161+
}
162+
163+
return true;
164+
}
165+
166+
return false;
167+
}
168+
169+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
<?php
2+
/**
3+
* Class RedditAPITest
4+
*
5+
* @created 09.04.2024
6+
* @author smiley <smiley@chillerlan.net>
7+
* @copyright 2024 smiley
8+
* @license MIT
9+
*/
10+
declare(strict_types=1);
11+
12+
namespace chillerlan\OAuthTest\Providers\Live;
13+
14+
use chillerlan\OAuth\Providers\Reddit;
15+
use PHPUnit\Framework\Attributes\Group;
16+
17+
/**
18+
* @property \chillerlan\OAuth\Providers\Reddit $provider
19+
*/
20+
#[Group('shortTokenExpiry')]
21+
#[Group('providerLiveTest')]
22+
class RedditAPITest extends OAuth2ProviderLiveTestAbstract{
23+
24+
protected function getProviderFQCN():string{
25+
return Reddit::class;
26+
}
27+
28+
protected function getEnvPrefix():string{
29+
return 'REDDIT';
30+
}
31+
32+
}
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
<?php
2+
/**
3+
* Class RedditTest
4+
*
5+
* @created 09.04.2024
6+
* @author smiley <smiley@chillerlan.net>
7+
* @copyright 2024 smiley
8+
* @license MIT
9+
*/
10+
declare(strict_types=1);
11+
12+
namespace chillerlan\OAuthTest\Providers\Unit;
13+
14+
use chillerlan\OAuth\Core\AccessToken;
15+
use chillerlan\OAuth\Core\TokenInvalidate;
16+
use chillerlan\OAuth\Providers\Reddit;
17+
18+
/**
19+
* @property \chillerlan\OAuth\Providers\Reddit $provider
20+
*/
21+
class RedditTest extends OAuth2ProviderUnitTestAbstract{
22+
23+
protected function getProviderFQCN():string{
24+
return Reddit::class;
25+
}
26+
27+
public function testGetAccessTokenRequestBodyParams():void{
28+
$params = $this->invokeReflectionMethod('getAccessTokenRequestBodyParams', ['*test_code*']);
29+
30+
$this::assertSame('*test_code*', $params['code']);
31+
$this::assertSame($this->options->callbackURL, $params['redirect_uri']);
32+
$this::assertSame('authorization_code', $params['grant_type']);
33+
}
34+
35+
public function testTokenInvalidate():void{
36+
37+
if(!$this->provider instanceof TokenInvalidate){
38+
$this::markTestSkipped('TokenInvalidate N/A');
39+
}
40+
41+
$token = new AccessToken(['expires' => 42]);
42+
43+
// Reddit responds with a 204
44+
$this->setMockResponse($this->responseFactory->createResponse(204));
45+
46+
$this->provider->storeAccessToken($token);
47+
48+
$this::assertTrue($this->storage->hasAccessToken($this->provider->name));
49+
$this::assertTrue($this->provider->invalidateAccessToken());
50+
$this::assertFalse($this->storage->hasAccessToken($this->provider->name));
51+
52+
// token via param
53+
54+
// the current token shouldn't be deleted
55+
$token2 = clone $token;
56+
$token2->accessToken = 'still here';
57+
58+
$this->provider->storeAccessToken($token2);
59+
60+
$this::assertTrue($this->provider->invalidateAccessToken($token));
61+
$this::assertSame('still here', $this->provider->getStorage()->getAccessToken($this->provider->name)->accessToken);
62+
}
63+
64+
}

0 commit comments

Comments
 (0)