361 lines
12 KiB
PHP
361 lines
12 KiB
PHP
<?php
|
|
|
|
namespace App\Services\OAuth;
|
|
|
|
use App\Exceptions\OAuthProtocolException;
|
|
use App\Models\OauthAccessToken;
|
|
use App\Models\OauthAuthorization;
|
|
use App\Models\OauthAuthorizationCode;
|
|
use App\Models\OauthClient;
|
|
use App\Models\OauthRefreshToken;
|
|
use App\Models\User;
|
|
use Illuminate\Support\Carbon;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Str;
|
|
|
|
class OAuthTokenService
|
|
{
|
|
public function __construct(
|
|
private readonly OAuthJwtService $jwtService,
|
|
private readonly OAuthClientAuthService $clientAuthService
|
|
) {}
|
|
|
|
/**
|
|
* @param array<int, string> $scopes
|
|
*/
|
|
public function issueAuthorizationCode(
|
|
User $user,
|
|
OauthClient $client,
|
|
array $scopes,
|
|
string $redirectUri,
|
|
?string $nonce = null
|
|
): string {
|
|
$scope = $this->clientAuthService->buildScopeString($scopes);
|
|
$scopeFingerprint = $this->scopeFingerprint($scopes);
|
|
|
|
$authorization = OauthAuthorization::query()->create([
|
|
'user_id' => $user->id,
|
|
'client_id' => $client->id,
|
|
'scope' => $scope,
|
|
'scope_fingerprint' => $scopeFingerprint,
|
|
'revoked_at' => null,
|
|
]);
|
|
|
|
$rawCode = $this->randomToken(48);
|
|
OauthAuthorizationCode::query()->create([
|
|
'code_hash' => hash('sha256', $rawCode),
|
|
'authorization_id' => $authorization->id,
|
|
'user_id' => $user->id,
|
|
'client_id' => $client->id,
|
|
'redirect_uri' => $redirectUri,
|
|
'scope' => $scope,
|
|
'nonce' => $nonce,
|
|
'expires_at' => now()->addSeconds((int) config('oauth.authorization_code_ttl_seconds', 300)),
|
|
'consumed_at' => null,
|
|
'revoked_at' => null,
|
|
]);
|
|
|
|
return $rawCode;
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>
|
|
*/
|
|
public function exchangeAuthorizationCode(OauthClient $client, string $code, string $redirectUri): array
|
|
{
|
|
return DB::transaction(function () use ($client, $code, $redirectUri): array {
|
|
$codeRecord = OauthAuthorizationCode::query()
|
|
->where('code_hash', hash('sha256', $code))
|
|
->lockForUpdate()
|
|
->first();
|
|
|
|
if (! $codeRecord) {
|
|
throw new OAuthProtocolException('invalid_grant', 'Invalid authorization code.');
|
|
}
|
|
|
|
if ((int) $codeRecord->client_id !== (int) $client->id) {
|
|
throw new OAuthProtocolException('invalid_grant', 'Authorization code does not belong to client.');
|
|
}
|
|
|
|
if ((string) $codeRecord->redirect_uri !== $redirectUri) {
|
|
throw new OAuthProtocolException('invalid_grant', 'redirect_uri mismatch.');
|
|
}
|
|
|
|
if ($codeRecord->revoked_at || $codeRecord->consumed_at || $codeRecord->expires_at->isPast()) {
|
|
throw new OAuthProtocolException('invalid_grant', 'Authorization code has expired or been used.');
|
|
}
|
|
|
|
$authorization = OauthAuthorization::query()
|
|
->lockForUpdate()
|
|
->find($codeRecord->authorization_id);
|
|
if (! $authorization || $authorization->revoked_at) {
|
|
throw new OAuthProtocolException('invalid_grant', 'Authorization is no longer valid.');
|
|
}
|
|
|
|
$codeRecord->consumed_at = now();
|
|
$codeRecord->save();
|
|
|
|
return $this->issueTokenSet(
|
|
$authorization,
|
|
$client,
|
|
(string) $codeRecord->scope,
|
|
$codeRecord->nonce,
|
|
createRefreshToken: true
|
|
);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>
|
|
*/
|
|
public function exchangeRefreshToken(OauthClient $client, string $refreshToken): array
|
|
{
|
|
return DB::transaction(function () use ($client, $refreshToken): array {
|
|
$tokenHash = hash('sha256', $refreshToken);
|
|
$refreshRecord = OauthRefreshToken::query()
|
|
->where('token_hash', $tokenHash)
|
|
->where('client_id', $client->id)
|
|
->lockForUpdate()
|
|
->first();
|
|
|
|
if (! $refreshRecord) {
|
|
throw new OAuthProtocolException('invalid_grant', 'Invalid refresh token.');
|
|
}
|
|
|
|
$authorization = OauthAuthorization::query()
|
|
->lockForUpdate()
|
|
->find($refreshRecord->authorization_id);
|
|
if (! $authorization || $authorization->revoked_at) {
|
|
throw new OAuthProtocolException('invalid_grant', 'Authorization is no longer valid.');
|
|
}
|
|
|
|
if ($refreshRecord->revoked_at || $refreshRecord->expires_at->isPast()) {
|
|
throw new OAuthProtocolException('invalid_grant', 'Refresh token expired or revoked.');
|
|
}
|
|
|
|
if ($refreshRecord->used_at !== null || $refreshRecord->replaced_by_refresh_token_id !== null) {
|
|
$refreshRecord->replayed_at = $refreshRecord->replayed_at ?? now();
|
|
$refreshRecord->save();
|
|
$this->revokeAuthorizationChain((int) $authorization->id);
|
|
|
|
throw new OAuthProtocolException('invalid_grant', 'Refresh token reuse detected.');
|
|
}
|
|
|
|
$refreshRecord->used_at = now();
|
|
$refreshRecord->save();
|
|
|
|
$tokenSet = $this->issueTokenSet(
|
|
$authorization,
|
|
$client,
|
|
(string) $authorization->scope,
|
|
nonce: null,
|
|
createRefreshToken: true,
|
|
previousRefreshTokenId: (int) $refreshRecord->id
|
|
);
|
|
|
|
if (! isset($tokenSet['_refresh_record_id'])) {
|
|
throw new OAuthProtocolException('server_error', 'Failed to rotate refresh token.', 500);
|
|
}
|
|
|
|
$refreshRecord->replaced_by_refresh_token_id = (int) $tokenSet['_refresh_record_id'];
|
|
$refreshRecord->save();
|
|
unset($tokenSet['_refresh_record_id']);
|
|
|
|
return $tokenSet;
|
|
});
|
|
}
|
|
|
|
public function revokeTokenForClient(OauthClient $client, string $token): void
|
|
{
|
|
if ($token === '') {
|
|
return;
|
|
}
|
|
|
|
$tokenHash = hash('sha256', $token);
|
|
$refreshRecord = OauthRefreshToken::query()
|
|
->where('token_hash', $tokenHash)
|
|
->where('client_id', $client->id)
|
|
->first();
|
|
if ($refreshRecord) {
|
|
$this->revokeAuthorizationChain((int) $refreshRecord->authorization_id);
|
|
|
|
return;
|
|
}
|
|
|
|
$payload = $this->jwtService->verify($token);
|
|
$jti = is_array($payload) ? (string) ($payload['jti'] ?? '') : '';
|
|
if ($jti === '') {
|
|
return;
|
|
}
|
|
|
|
$accessToken = OauthAccessToken::query()
|
|
->where('jti', $jti)
|
|
->where('client_id', $client->id)
|
|
->first();
|
|
if ($accessToken && ! $accessToken->revoked_at) {
|
|
$accessToken->revoked_at = now();
|
|
$accessToken->save();
|
|
}
|
|
}
|
|
|
|
public function validateAccessToken(string $token): ?OauthAccessToken
|
|
{
|
|
$payload = $this->jwtService->verify($token);
|
|
if (! is_array($payload)) {
|
|
return null;
|
|
}
|
|
|
|
$jti = (string) ($payload['jti'] ?? '');
|
|
if ($jti === '') {
|
|
return null;
|
|
}
|
|
|
|
$accessToken = OauthAccessToken::query()
|
|
->with(['authorization', 'user', 'client'])
|
|
->where('jti', $jti)
|
|
->first();
|
|
if (! $accessToken || $accessToken->revoked_at || $accessToken->expires_at->isPast()) {
|
|
return null;
|
|
}
|
|
|
|
if (! $accessToken->authorization || $accessToken->authorization->revoked_at) {
|
|
return null;
|
|
}
|
|
|
|
return $accessToken;
|
|
}
|
|
|
|
/**
|
|
* @param array<int, string> $scopes
|
|
*/
|
|
public function scopeFingerprint(array $scopes): string
|
|
{
|
|
$normalized = collect($scopes)
|
|
->map(fn (string $scope): string => trim($scope))
|
|
->filter()
|
|
->unique()
|
|
->sort()
|
|
->values()
|
|
->implode('|');
|
|
|
|
return hash('sha256', $normalized);
|
|
}
|
|
|
|
public function revokeAuthorizationChain(int $authorizationId): void
|
|
{
|
|
OauthAuthorization::query()
|
|
->where('id', $authorizationId)
|
|
->whereNull('revoked_at')
|
|
->update(['revoked_at' => now()]);
|
|
|
|
OauthAuthorizationCode::query()
|
|
->where('authorization_id', $authorizationId)
|
|
->whereNull('revoked_at')
|
|
->update(['revoked_at' => now()]);
|
|
|
|
OauthAccessToken::query()
|
|
->where('authorization_id', $authorizationId)
|
|
->whereNull('revoked_at')
|
|
->update(['revoked_at' => now()]);
|
|
|
|
OauthRefreshToken::query()
|
|
->where('authorization_id', $authorizationId)
|
|
->whereNull('revoked_at')
|
|
->update(['revoked_at' => now()]);
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function issueTokenSet(
|
|
OauthAuthorization $authorization,
|
|
OauthClient $client,
|
|
string $scope,
|
|
?string $nonce,
|
|
bool $createRefreshToken = true,
|
|
?int $previousRefreshTokenId = null
|
|
): array {
|
|
$now = now();
|
|
$accessTokenTtl = (int) config('oauth.access_token_ttl_seconds', 3600);
|
|
$refreshTokenTtl = (int) config('oauth.refresh_token_ttl_seconds', 1209600);
|
|
$scopeList = $this->clientAuthService->parseScopes($scope);
|
|
$scopeString = $this->clientAuthService->buildScopeString($scopeList);
|
|
$subject = (string) $authorization->user_id;
|
|
|
|
$accessJti = (string) Str::uuid();
|
|
$accessPayload = [
|
|
'iss' => (string) config('oauth.issuer'),
|
|
'sub' => $subject,
|
|
'aud' => $client->client_id,
|
|
'exp' => $now->copy()->addSeconds($accessTokenTtl)->timestamp,
|
|
'iat' => $now->timestamp,
|
|
'jti' => $accessJti,
|
|
'scope' => $scopeString,
|
|
'client_id' => $client->client_id,
|
|
];
|
|
$accessToken = $this->jwtService->sign($accessPayload);
|
|
|
|
OauthAccessToken::query()->create([
|
|
'jti' => $accessJti,
|
|
'authorization_id' => $authorization->id,
|
|
'user_id' => $authorization->user_id,
|
|
'client_id' => $authorization->client_id,
|
|
'scope' => $scopeString,
|
|
'expires_at' => Carbon::createFromTimestamp($accessPayload['exp']),
|
|
'revoked_at' => null,
|
|
]);
|
|
|
|
$result = [
|
|
'access_token' => $accessToken,
|
|
'token_type' => 'Bearer',
|
|
'expires_in' => $accessTokenTtl,
|
|
'scope' => $scopeString,
|
|
];
|
|
|
|
if ($createRefreshToken) {
|
|
$rawRefreshToken = $this->randomToken(64);
|
|
$refreshJti = (string) Str::uuid();
|
|
$refreshRecord = OauthRefreshToken::query()->create([
|
|
'jti' => $refreshJti,
|
|
'token_hash' => hash('sha256', $rawRefreshToken),
|
|
'authorization_id' => $authorization->id,
|
|
'user_id' => $authorization->user_id,
|
|
'client_id' => $authorization->client_id,
|
|
'previous_refresh_token_id' => $previousRefreshTokenId,
|
|
'replaced_by_refresh_token_id' => null,
|
|
'expires_at' => now()->addSeconds($refreshTokenTtl),
|
|
'used_at' => null,
|
|
'replayed_at' => null,
|
|
'revoked_at' => null,
|
|
]);
|
|
|
|
$result['refresh_token'] = $rawRefreshToken;
|
|
$result['_refresh_record_id'] = $refreshRecord->id;
|
|
}
|
|
|
|
if (in_array('openid', $scopeList, true)) {
|
|
$idTokenPayload = [
|
|
'iss' => (string) config('oauth.issuer'),
|
|
'sub' => $subject,
|
|
'aud' => $client->client_id,
|
|
'exp' => $now->copy()->addSeconds($accessTokenTtl)->timestamp,
|
|
'iat' => $now->timestamp,
|
|
'auth_time' => $authorization->created_at?->timestamp ?? $now->timestamp,
|
|
];
|
|
|
|
if ($nonce !== null && trim($nonce) !== '') {
|
|
$idTokenPayload['nonce'] = trim($nonce);
|
|
}
|
|
$idTokenPayload['at_hash'] = $this->jwtService->atHash($accessToken);
|
|
$result['id_token'] = $this->jwtService->sign($idTokenPayload);
|
|
}
|
|
|
|
return $result;
|
|
}
|
|
|
|
private function randomToken(int $bytes = 48): string
|
|
{
|
|
return rtrim(strtr(base64_encode(random_bytes($bytes)), '+/', '-_'), '=');
|
|
}
|
|
}
|