$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 */ 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 */ 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 $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 */ 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)), '+/', '-_'), '='); } }