BastionSSO/app/Services/OAuth/OAuthTokenService.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)), '+/', '-_'), '=');
}
}