1<?php
2
3// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the GNU Affero General Public License v3.0.
4// See the LICENCE file in the repository root for full licence text.
5
6namespace App\Models\OAuth;
7
8use App\Events\UserSessionEvent;
9use App\Exceptions\InvalidScopeException;
10use App\Interfaces\SessionVerificationInterface;
11use App\Models\Traits\FasterAttributes;
12use App\Models\User;
13use Ds\Set;
14use Illuminate\Database\Eloquent\Factories\HasFactory;
15use Laravel\Passport\RefreshToken;
16use Laravel\Passport\Token as PassportToken;
17
18class Token extends PassportToken implements SessionVerificationInterface
19{
20 // PassportToken doesn't have factory
21 use HasFactory, FasterAttributes;
22
23 protected $casts = [
24 'expires_at' => 'datetime',
25 'revoked' => 'boolean',
26 'scopes' => 'array',
27 'verified' => 'boolean',
28 ];
29
30 private ?Set $scopeSet;
31
32 public static function findForVerification(string $id): ?static
33 {
34 return static::find($id);
35 }
36
37 public function refreshToken()
38 {
39 return $this->hasOne(RefreshToken::class, 'access_token_id');
40 }
41
42 public function user()
43 {
44 return $this->belongsTo(User::class, 'user_id');
45 }
46
47 /**
48 * Whether the resource owner is delegated to the client's owner.
49 *
50 * @return bool
51 */
52 public function delegatesOwner(): bool
53 {
54 return $this->scopeSet()->contains('delegate');
55 }
56
57 public function getAttribute($key)
58 {
59 return match ($key) {
60 'client_id',
61 'id',
62 'name',
63 'user_id' => $this->getRawAttribute($key),
64
65 'revoked',
66 'verified' => $this->getNullableBool($key),
67
68 'scopes' => json_decode($this->getRawAttribute($key) ?? 'null', true),
69
70 'created_at',
71 'expires_at',
72 'updated_at' => $this->getTimeFast($key),
73
74 'client',
75 'refreshToken',
76 'user' => $this->getRelationValue($key),
77 };
78 }
79
80 public function getKeyForEvent(): string
81 {
82 return "oauth:{$this->getKey()}";
83 }
84
85 /**
86 * Resource owner for the token.
87 *
88 * For client_credentials grants, this is the client that requested the token;
89 * otherwise, it is the user that authorized the token.
90 */
91 public function getResourceOwner(): ?User
92 {
93 if ($this->isClientCredentials() && $this->delegatesOwner()) {
94 return $this->client->user;
95 }
96
97 return $this->user;
98 }
99
100 public function isClientCredentials()
101 {
102 // explicitly no user_id.
103 return $this->user_id === null;
104 }
105
106 public function isOwnToken(): bool
107 {
108 $clientUserId = $this->client->user_id;
109
110 return $clientUserId !== null && $clientUserId === $this->user_id;
111 }
112
113 public function isVerified(): bool
114 {
115 return $this->verified;
116 }
117
118 public function markVerified(): void
119 {
120 $this->update(['verified' => true]);
121 }
122
123 public function revokeRecursive()
124 {
125 $result = $this->revoke();
126 $this->refreshToken?->revoke();
127
128 return $result;
129 }
130
131 public function revoke()
132 {
133 $saved = parent::revoke();
134
135 if ($saved && $this->user_id !== null) {
136 UserSessionEvent::newLogout($this->user_id, [$this->getKeyForEvent()])->broadcast();
137 }
138
139 return $saved;
140 }
141
142 public function scopeValidAt($query, $time)
143 {
144 return $query->where('revoked', false)->where('expires_at', '>', $time);
145 }
146
147 public function setScopesAttribute(?array $value)
148 {
149 if ($value !== null) {
150 sort($value);
151 }
152
153 $this->scopeSet = null;
154 $this->attributes['scopes'] = $this->castAttributeAsJson('scopes', $value);
155 }
156
157 public function userId(): ?int
158 {
159 return $this->user_id;
160 }
161
162 public function validate(): void
163 {
164 static $scopesRequireDelegation = new Set(['chat.write', 'chat.write_manage', 'delegate']);
165
166 $scopes = $this->scopeSet();
167 if ($scopes->isEmpty()) {
168 throw new InvalidScopeException('Tokens without scopes are not valid.');
169 }
170
171 $client = $this->client;
172 if ($client === null) {
173 throw new InvalidScopeException('The client is not authorized.', 'unauthorized_client');
174 }
175
176 // no silly scopes.
177 if ($scopes->contains('*')) {
178 if ($scopes->count() > 1) {
179 throw new InvalidScopeException('* is not valid with other scopes');
180 }
181 } elseif ($client->user === null) {
182 // Only "*" scope is allowed for clients with no user
183 throw new InvalidScopeException('The client is missing owner.');
184 }
185
186 if ($this->isClientCredentials()) {
187 if ($scopes->contains('*')) {
188 throw new InvalidScopeException('* is not allowed with Client Credentials');
189 }
190
191 if ($this->delegatesOwner() && !$client->user->isBot()) {
192 throw new InvalidScopeException('Delegation with Client Credentials is only available to chat bots.');
193 }
194
195 if (!$scopes->intersect($scopesRequireDelegation)->isEmpty()) {
196 if (!$this->delegatesOwner()) {
197 throw new InvalidScopeException('delegate scope is required.');
198 }
199
200 // delegation is only allowed if scopes given allow delegation.
201 if (!$scopes->diff($scopesRequireDelegation)->isEmpty()) {
202 throw new InvalidScopeException('delegation is not supported for this combination of scopes.');
203 }
204 }
205 } else {
206 // delegation is only available for client_credentials.
207 if ($this->delegatesOwner()) {
208 throw new InvalidScopeException('delegate scope is only valid for client_credentials tokens.');
209 }
210
211 // only clients owned by bots are allowed to act on behalf of another user.
212 // the user's own client can send messages as themselves for authorization code flows.
213 static $ownClientScopes = new Set([
214 'chat.read',
215 'chat.write',
216 'chat.write_manage',
217 ]);
218 if (!$scopes->intersect($ownClientScopes)->isEmpty() && !($this->isOwnToken() || $client->user->isBot())) {
219 throw new InvalidScopeException('This scope is only available for chat bots or your own clients.');
220 }
221 }
222 }
223
224 public function save(array $options = [])
225 {
226 // Forces error if passport tries to issue an invalid client_credentials token.
227 $this->validate();
228 if (!$this->exists) {
229 $this->setVerifiedState();
230 }
231
232 return parent::save($options);
233 }
234
235 private function scopeSet(): Set
236 {
237 return $this->scopeSet ??= new Set($this->scopes ?? []);
238 }
239
240 private function setVerifiedState(): void
241 {
242 // client credential doesn't have user attached and auth code is
243 // already verified during grant process
244 $this->verified ??= $GLOBALS['cfg']['osu']['user']['bypass_verification']
245 || $this->user === null
246 || !$this->client->password_client;
247 }
248}